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

Hyshmily / hotkey / 28356107387

29 Jun 2026 07:35AM UTC coverage: 91.168% (-0.01%) from 91.178%
28356107387

push

github

Hyshmily
test : fix CI tests

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

1276 of 1461 branches covered (87.34%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 4 files covered. (100.0%)

117 existing lines in 15 files now uncovered.

3596 of 3883 relevant lines covered (92.61%)

4.24 hits per line

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

95.73
/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<?>> 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
   * Triggers an asynchronous background refresh for the given cache key if the
278
   * current entry has reached its soft expiry threshold. The caller (typically
279
   * {@link #getWithSoftExpire}) has already returned the stale value to the
280
   * client, so this method executes entirely in the background without blocking
281
   * the caller.
282
   *
283
   * <p><b>Concurrency:</b> uses {@link ConcurrentHashMap#compute} on
284
   * {@code pendingRefreshes} to atomically decide whether a new refresh task
285
   * should be launched. This eliminates the earlier DCL (double-checked locking)
286
   * pattern that required manual clean-up of a placeholder
287
   * {@link CompletableFuture} when the limiter rejected the task or the executor
288
   * was saturated.
289
   *
290
   * @param cacheKey  the key whose value should be refreshed
291
   * @param reader    the data-source supplier
292
   * @param softTtlMs the soft TTL to set on the refreshed entry (milliseconds)
293
   */
294
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
295
    if (!isSoftExpireEnabled()) {
3✔
296
      return;
1✔
297
    }
298
    pendingRefreshes.compute(cacheKey, (k, existing) -> {
10✔
299
      // If there is already an in-flight refresh for this key, keep the
300
      // existing future and do nothing.
301
      if (existing != null && !existing.isDone()) {
5!
302
        return existing;
2✔
303
      }
304

305
      // Try to acquire a permit from the global refresh limiter.
306
      if (!refreshLimiter.tryAcquire()) {
4✔
307
        log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
308
        // Returning null removes any previous entry, leaving no stale marker.
309
        return null;
2✔
310
      }
311

312
      // Snapshot the current data version so that we can detect whether a
313
      // newer write has superseded the cache entry while this refresh was
314
      // in-flight.
315
      long refreshStartDataVersion = Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
7✔
316
        .filter(CacheEntry.class::isInstance)
6✔
317
        .map(CacheEntry.class::cast)
5✔
318
        .map(CacheEntry::getDataVersion)
2✔
319
        .orElse(VERSION_DEFAULT);
5✔
320

321
      // Build the async refresh task with timeout protection.
322
      CompletableFuture<?> task;
323
      try {
324
        task = CompletableFuture.supplyAsync(reader, executor).orTimeout(refreshTimeoutSeconds, TimeUnit.SECONDS);
8✔
325
      } catch (RejectedExecutionException e) {
1✔
326
        // Executor saturated – release the limiter permit and leave no
327
        // pending marker so the next read can retry immediately.
328
        refreshLimiter.release();
3✔
329
        log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
330
        return null;
2✔
331
      }
1✔
332

333
      // When the refresh completes (success, failure or timeout), update the
334
      // cache entry if the value is still applicable, and always release the
335
      // resources.
336
      task.whenComplete((value, error) -> {
8✔
337
        try {
338
          if (error != null) {
2✔
339
            if (error instanceof TimeoutException) {
3!
UNCOV
340
              log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
341
            } else {
342
              log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
343
            }
344
            return;
1✔
345
          }
346
          if (value != null) {
2✔
347
            caffeineCache
2✔
348
              .asMap()
8✔
349
              .compute(cacheKey, (key, existingEntry) ->
2✔
350
                Optional.ofNullable(existingEntry)
5✔
351
                  .filter(CacheEntry.class::isInstance)
6✔
352
                  .map(CacheEntry.class::cast)
10✔
353
                  .map(entry -> {
5✔
354
                    // Version guard: if a newer write has
355
                    // arrived while we were refreshing,
356
                    // discard the stale refresh result.
357
                    if (entry.getDataVersion() > refreshStartDataVersion) {
5✔
358
                      log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
359
                      return entry;
2✔
360
                    }
361
                    return entry
2✔
362
                      .toBuilder()
2✔
363
                      .value(value)
2✔
364
                      .softTtlMs(softTtlMs)
3✔
365
                      .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
366
                      .build();
1✔
367
                  })
368
                  .orElseGet(() ->
1✔
369
                    CacheEntry.builder()
3✔
370
                      .value(value)
2✔
371
                      .dataVersion(VERSION_DEFAULT)
2✔
372
                      .isVersionDegraded(false)
2✔
373
                      .decisionVersion(0L)
2✔
374
                      .hardTtlMs(0L)
2✔
375
                      .hardExpireAtMs(Long.MAX_VALUE)
2✔
376
                      .softTtlMs(softTtlMs)
3✔
377
                      .softExpireAtMs(computeSoftExpireAt(softTtlMs))
3✔
378
                      .keyState(KeyState.NORMAL)
2✔
379
                      .normalHardTtlMs(0L)
2✔
380
                      .normalSoftTtlMs(0L)
1✔
381
                      .build()
1✔
382
                  )
383
              );
384
          }
385
        } finally {
386
          // Always release the limiter permit and remove the in-flight
387
          // marker so that a future refresh can be scheduled.
388
          refreshLimiter.release();
3✔
389
          pendingRefreshes.remove(cacheKey);
5✔
390
        }
391
      });
1✔
392

393
      // Return the new task; it will be stored in the map and visible to
394
      // any concurrent caller for this key.
395
      return task;
2✔
396
    });
397
  }
1✔
398
}
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