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

Hyshmily / hotkey / 28705436176

04 Jul 2026 11:56AM UTC coverage: 90.132% (-0.3%) from 90.399%
28705436176

push

github

Hyshmily
Merge remote-tracking branch 'hotkey/master'

1261 of 1453 branches covered (86.79%)

Branch coverage included in aggregate %.

3589 of 3928 relevant lines covered (91.37%)

4.18 hits per line

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

80.24
/common/src/main/java/io/github/hyshmily/hotkey/cache/cachesupport/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.cachesupport;
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.Internal;
22
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
23
import io.github.hyshmily.hotkey.model.CacheEntry;
24
import io.github.hyshmily.hotkey.model.KeyState;
25
import io.github.hyshmily.hotkey.util.DelayUtil;
26
import io.github.hyshmily.hotkey.util.TimeSource;
27
import java.util.Optional;
28
import java.util.concurrent.*;
29
import java.util.function.Supplier;
30
import lombok.Getter;
31
import lombok.extern.slf4j.Slf4j;
32

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

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

58
  private static final long refreshTimeoutSeconds = 30;
59

60
  private static final int BLOCKED_TAG = -1;
61

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

85
  /**
86
   * Create a CacheExpireManager with explicit jitter ratio (for testing).
87
   */
88
  CacheExpireManager(
89
    Cache<String, Object> caffeineCache,
90
    Executor executor,
91
    HotKeyProperties ttlConfig,
92
    int refreshMaxPools,
93
    double defaultTtlJitterRatio
94
  ) {
×
95
    this.caffeineCache = caffeineCache;
×
96
    this.executor = executor;
×
97
    this.ttlConfig = ttlConfig;
×
98
    this.refreshLimiter = ttlConfig.isSoftExpireEnabled()
×
99
      ? new Semaphore(refreshMaxPools > 0 ? refreshMaxPools : 100)
×
100
      : null;
×
101
    this.defaultTtlJitterRatio = defaultTtlJitterRatio;
×
102
  }
×
103

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

113
  /**
114
   * Check whether a {@link CacheEntry} has logically expired based on its
115
   * {@code hardExpireAtMs}.  Entries with {@code hardExpireAtMs == Long.MAX_VALUE}
116
   * are treated as permanent (never logically expire).
117
   *
118
   * @param entry the cache entry to inspect
119
   * @return {@code true} if the entry has logically expired
120
   */
121
  public boolean isLogicallyExpired(CacheEntry entry) {
122
    return entry.getHardExpireAtMs() != Long.MAX_VALUE && TimeSource.currentTimeMillis() >= entry.getHardExpireAtMs();
14✔
123
  }
124

125
  /**
126
   * Check whether the given raw cache value is a logically expired {@link CacheEntry}
127
   * and, if so, invalidate it and return {@code true}.
128
   * <p>Eliminates code duplication between {@link io.github.hyshmily.hotkey.cache.HotKeyCache#get}
129
   * and {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire},
130
   * which both perform this check before and after side effects (TOCTOU guard).
131
   *
132
   * @param cacheKey the cache key to invalidate if expired
133
   * @param raw      the raw value from the Caffeine cache
134
   * @return {@code true} if the entry was expired and has been invalidated
135
   */
136
  public boolean invalidateIfIsLogicallyExpired(String cacheKey, Object raw) {
137
    if (raw instanceof CacheEntry ce && isLogicallyExpired(ce)) {
10✔
138
      caffeineCache.invalidate(cacheKey);
4✔
139
      log.debug("Cache entry logically expired during processing, reloading: {}", cacheKey);
4✔
140
      return true;
2✔
141
    }
142
    return false;
2✔
143
  }
144

145
  public long computeNullExpireAt(long nullTtlMs) {
146
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
10✔
147
    return toHardExpireTimestamp(effective);
4✔
148
  }
149

150
  /**
151
   * Hard expire timestamp from an explicit TTL duration.
152
   * Falls back to the normal-key default if {@code hardTtlMs <= 0}.
153
   *
154
   * @param hardTtlMs the hard TTL duration in milliseconds (&lt;= 0 uses configured default)
155
   * @return absolute epoch-ms timestamp for hard expiry
156
   */
157
  public long computeHardExpireAt(long hardTtlMs) {
158
    return toHardExpireTimestamp(resolveEffectiveHardTtl(hardTtlMs));
6✔
159
  }
160

161
  /**
162
   * Hard expire timestamp for hot keys, using {@code default-hot-hard-ttl} / {@code hot-hard-ttl}.
163
   * Returns {@code Long.MAX_VALUE} if hot hard expire is disabled (TTL &lt;= 0).
164
   *
165
   * @return absolute epoch-ms timestamp for hot-key hard expiry
166
   */
167
  public long computeHotHardExpireAt() {
168
    return toHardExpireTimestamp(ttlConfig.effectiveHotHardTtlMs());
6✔
169
  }
170

171
  /**
172
   * Soft expire timestamp for hot keys, using {@code default-hot-soft-ttl} / {@code hot-soft-ttl}.
173
   *
174
   * @return absolute epoch-ms timestamp for hot-key soft expiry, or 0 if disabled
175
   */
176
  public long computeHotSoftExpireAt() {
177
    return toSoftExpireTimestamp(ttlConfig.effectiveHotSoftTtlMs());
6✔
178
  }
179

180
  /**
181
   * Soft expire timestamp from an explicit TTL duration.
182
   * Falls back to the normal-key default if {@code softTtlMs <= 0}. Returns 0 if soft expire is disabled.
183
   *
184
   * @param softTtlMs the soft TTL duration in milliseconds (&lt;= 0 uses configured default)
185
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
186
   */
187
  public long computeSoftExpireAt(long softTtlMs) {
188
    if (!isSoftExpireEnabled()) {
3✔
189
      return 0L;
2✔
190
    }
191
    return toSoftExpireTimestamp(resolveEffectiveSoftTtl(softTtlMs));
6✔
192
  }
193

194
  /**
195
   * Effective hard TTL for normal keys (override > default).
196
   *
197
   * @return effective hard TTL duration in milliseconds
198
   */
199
  public long getEffectiveHardTtlMs() {
200
    return ttlConfig.effectiveHardTtlMs();
4✔
201
  }
202

203
  /**
204
   * Resolve effective hard TTL for normal keys: use the override value if
205
   * positive, otherwise fall back to the configured default.
206
   *
207
   * @param hardTtlMs hard TTL override ({@code 0} or negative uses default)
208
   * @return effective hard TTL duration in milliseconds
209
   */
210
  public long resolveEffectiveHardTtl(long hardTtlMs) {
211
    return hardTtlMs > 0 ? hardTtlMs : getEffectiveHardTtlMs();
9✔
212
  }
213

214
  /**
215
   * Effective hard TTL for hot keys (override > default).
216
   *
217
   * @return effective hot hard TTL duration in milliseconds
218
   */
219
  public long getEffectiveHotHardTtlMs() {
220
    return ttlConfig.effectiveHotHardTtlMs();
4✔
221
  }
222

223
  /**
224
   * Resolve effective hard TTL for hot keys: use the override value if
225
   * positive, otherwise fall back to the configured hot-key hard TTL.
226
   *
227
   * @param hardTtlMs hard TTL override ({@code 0} or negative uses default)
228
   * @return effective hot-key hard TTL duration in milliseconds
229
   */
230
  public long resolveEffectiveHotHard(long hardTtlMs) {
231
    return hardTtlMs > 0 ? hardTtlMs : getEffectiveHotHardTtlMs();
9✔
232
  }
233

234
  /**
235
   * Effective soft TTL for normal keys (override > default).
236
   *
237
   * @return effective soft TTL duration in milliseconds
238
   */
239
  public long getEffectiveSoftTtlMs() {
240
    return ttlConfig.effectiveSoftTtlMs();
4✔
241
  }
242

243
  /**
244
   * Resolve effective soft TTL for normal keys: use the override value if
245
   * positive, otherwise fall back to the configured default.
246
   *
247
   * @param softTtlMs soft TTL override ({@code 0} or negative uses default)
248
   * @return effective soft TTL duration in milliseconds
249
   */
250
  public long resolveEffectiveSoftTtl(long softTtlMs) {
251
    return softTtlMs > 0 ? softTtlMs : getEffectiveSoftTtlMs();
9✔
252
  }
253

254
  /**
255
   * Effective soft TTL for hot keys (override > default).
256
   *
257
   * @return effective hot soft TTL duration in milliseconds
258
   */
259
  public long getEffectiveHotSoftTtlMs() {
260
    return ttlConfig.effectiveHotSoftTtlMs();
4✔
261
  }
262

263
  /**
264
   * Resolve effective soft TTL for hot keys: use the override value if
265
   * positive, otherwise fall back to the configured hot-key soft TTL.
266
   *
267
   * @param softTtlMs soft TTL override ({@code 0} or negative uses default)
268
   * @return effective hot-key soft TTL duration in milliseconds
269
   */
270
  public long resolveEffectiveHotSoft(long softTtlMs) {
271
    return softTtlMs > 0 ? softTtlMs : getEffectiveHotSoftTtlMs();
9✔
272
  }
273

274
  /**
275
   * Build a {@link CacheEntry} from fully resolved fields, including
276
   * decision metadata (node, epoch), pre-computed expire timestamps,
277
   * and normal-TTL values. Normal TTL is applied via
278
   * {@link #applyNormalTtl} after construction.
279
   * <p>
280
   * This overload accepts pre-computed hard/soft expire timestamps,
281
   * which is useful when the caller already knows the exact expiry
282
   * baseline (e.g., when copying from an existing entry).
283
   *
284
   * @param value              the cached value
285
   * @param dataVersion        the data version for cross-instance sync
286
   * @param isVersionDegraded  whether the data version is degraded (local fallback)
287
   * @param decisionVersion    the Worker decision version
288
   * @param decisionNodeId     the Worker node ID that produced the decision
289
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
290
   * @param hardTtlMs          hard TTL duration in milliseconds
291
   * @param softTtlMs          soft TTL duration in milliseconds
292
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
293
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
294
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
295
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
296
   * @param keyState           the initial key state (NORMAL, HOT, COOL)
297
   * @return a new {@link CacheEntry} with all fields set
298
   */
299
  public CacheEntry createBuilder(
300
    Object value,
301
    long dataVersion,
302
    boolean isVersionDegraded,
303
    long decisionVersion,
304
    String decisionNodeId,
305
    long decisionEpoch,
306
    long hardTtlMs,
307
    long softTtlMs,
308
    long hardExpireAtMs,
309
    long softExpireAtMs,
310
    long normalHardTtlMs,
311
    long normalSoftTtlMs,
312
    KeyState keyState
313
  ) {
314
    return applyNormalTtl(
×
315
      CacheEntry.builder()
×
316
        .value(value)
×
317
        .dataVersion(dataVersion)
×
318
        .isVersionDegraded(isVersionDegraded)
×
319
        .decisionVersion(decisionVersion)
×
320
        .decisionNodeId(decisionNodeId)
×
321
        .decisionEpoch(decisionEpoch)
×
322
        .hardTtlMs(hardTtlMs)
×
323
        .softTtlMs(softTtlMs)
×
324
        .hardExpireAtMs(hardExpireAtMs)
×
325
        .softExpireAtMs(softExpireAtMs)
×
326
        .keyState(keyState)
×
327
        .build(),
×
328
      normalHardTtlMs,
329
      normalSoftTtlMs
330
    );
331
  }
332

333
  /**
334
   * Build a {@link CacheEntry} from fully resolved fields with decision
335
   * metadata but without pre-computed expire timestamps. Expire timestamps
336
   * are computed automatically via {@link #applyTtl}.
337
   * <p>
338
   * The {@code hardTtlMs} and {@code softTtlMs} passed here are used both
339
   * as field values <em>and</em> as inputs to {@code applyTtl}, which
340
   * overwrites the expire-at timestamps. This is the typical path for
341
   * entries sourced from a remote reader (Worker or Redis) where the
342
   * caller does not pre-compute timestamps.
343
   *
344
   * @param value              the cached value
345
   * @param dataVersion        the data version for cross-instance sync
346
   * @param isVersionDegraded  whether the data version is degraded
347
   * @param decisionVersion    the Worker decision version
348
   * @param decisionNodeId     the Worker node ID that produced the decision
349
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
350
   * @param hardTtlMs          hard TTL duration in milliseconds
351
   * @param softTtlMs          soft TTL duration in milliseconds
352
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
353
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
354
   * @param keyState           the initial key state
355
   * @return a new {@link CacheEntry} with expire timestamps computed
356
   */
357
  public CacheEntry createBuilder(
358
    Object value,
359
    long dataVersion,
360
    boolean isVersionDegraded,
361
    long decisionVersion,
362
    String decisionNodeId,
363
    long decisionEpoch,
364
    long hardTtlMs,
365
    long softTtlMs,
366
    long normalHardTtlMs,
367
    long normalSoftTtlMs,
368
    KeyState keyState
369
  ) {
370
    return applyTtl(
4✔
371
      applyNormalTtl(
3✔
372
        CacheEntry.builder()
2✔
373
          .value(value)
2✔
374
          .dataVersion(dataVersion)
2✔
375
          .isVersionDegraded(isVersionDegraded)
2✔
376
          .decisionVersion(decisionVersion)
2✔
377
          .decisionNodeId(decisionNodeId)
2✔
378
          .decisionEpoch(decisionEpoch)
2✔
379
          .hardTtlMs(hardTtlMs)
2✔
380
          .softTtlMs(softTtlMs)
2✔
381
          .keyState(keyState)
1✔
382
          .build(),
3✔
383
        normalHardTtlMs,
384
        normalSoftTtlMs
385
      ),
386
      hardTtlMs,
387
      softTtlMs
388
    );
389
  }
390

391
  /**
392
   * Build a {@link CacheEntry} with pre-computed expire timestamps
393
   * but without decision node/epoch metadata. Normal TTL is applied
394
   * via {@link #applyNormalTtl} after construction.
395
   * <p>
396
   * This overload omits {@code decisionNodeId} and {@code decisionEpoch},
397
   * which is appropriate for entries created by local promotion (no
398
   * Worker origin). The expire timestamps are caller-supplied.
399
   *
400
   * @param value              the cached value
401
   * @param dataVersion        the data version for cross-instance sync
402
   * @param isVersionDegraded  whether the data version is degraded
403
   * @param decisionVersion    the Worker decision version (0 for local)
404
   * @param hardTtlMs          hard TTL duration in milliseconds
405
   * @param softTtlMs          soft TTL duration in milliseconds
406
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
407
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
408
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
409
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
410
   * @param keyState           the initial key state
411
   * @return a new {@link CacheEntry} with all fields set
412
   */
413
  public CacheEntry createBuilder(
414
    Object value,
415
    long dataVersion,
416
    boolean isVersionDegraded,
417
    long decisionVersion,
418
    long hardTtlMs,
419
    long softTtlMs,
420
    long hardExpireAtMs,
421
    long softExpireAtMs,
422
    long normalHardTtlMs,
423
    long normalSoftTtlMs,
424
    KeyState keyState
425
  ) {
426
    return applyNormalTtl(
3✔
427
      CacheEntry.builder()
2✔
428
        .value(value)
2✔
429
        .dataVersion(dataVersion)
2✔
430
        .isVersionDegraded(isVersionDegraded)
2✔
431
        .decisionVersion(decisionVersion)
2✔
432
        .hardTtlMs(hardTtlMs)
2✔
433
        .softTtlMs(softTtlMs)
2✔
434
        .hardExpireAtMs(hardExpireAtMs)
2✔
435
        .softExpireAtMs(softExpireAtMs)
2✔
436
        .keyState(keyState)
1✔
437
        .build(),
3✔
438
      normalHardTtlMs,
439
      normalSoftTtlMs
440
    );
441
  }
442

443
  /**
444
   * Build a {@link CacheEntry} from raw fields without pre-computed
445
   * expire timestamps or decision metadata. Expire timestamps are
446
   * computed via {@link #applyTtl} after normal TTL is set.
447
   * <p>
448
   * This is the most compact overload, suitable for local promotions
449
   * where the caller has no Worker decision context and wants
450
   * timestamps computed automatically.
451
   *
452
   * @param value              the cached value
453
   * @param dataVersion        the data version for cross-instance sync
454
   * @param isVersionDegraded  whether the data version is degraded
455
   * @param decisionVersion    the Worker decision version (0 for local)
456
   * @param hardTtlMs          hard TTL duration in milliseconds
457
   * @param softTtlMs          soft TTL duration in milliseconds
458
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
459
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
460
   * @param keyState           the initial key state
461
   * @return a new {@link CacheEntry} with expire timestamps computed
462
   */
463
  public CacheEntry createBuilder(
464
    Object value,
465
    long dataVersion,
466
    boolean isVersionDegraded,
467
    long decisionVersion,
468
    long hardTtlMs,
469
    long softTtlMs,
470
    long normalHardTtlMs,
471
    long normalSoftTtlMs,
472
    KeyState keyState
473
  ) {
474
    return applyTtl(
×
475
      applyNormalTtl(
×
476
        CacheEntry.builder()
×
477
          .value(value)
×
478
          .dataVersion(dataVersion)
×
479
          .isVersionDegraded(isVersionDegraded)
×
480
          .decisionVersion(decisionVersion)
×
481
          .keyState(keyState)
×
482
          .build(),
×
483
        normalHardTtlMs,
484
        normalSoftTtlMs
485
      ),
486
      hardTtlMs,
487
      softTtlMs
488
    );
489
  }
490

491
  /**
492
   * Create a copy of the entry with the normal (non-hot) TTL values set,
493
   * leaving all other fields (hot TTLs, versions, state) untouched.
494
   * <p>
495
   * The normal TTLs ({@code normalHardTtlMs}, {@code normalSoftTtlMs})
496
   * are the baseline TTL values that the entry reverts to when its key
497
   * state transitions from HOT back to NORMAL. These are recorded at
498
   * entry creation and preserved across state transitions.
499
   *
500
   * @param original   the source {@link CacheEntry} to copy
501
   * @param hardTtlMs  normal hard TTL duration in milliseconds
502
   * @param softTtlMs  normal soft TTL duration in milliseconds
503
   * @return a new {@link CacheEntry} with the normal TTL fields updated
504
   */
505
  public CacheEntry applyNormalTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
506
    return original.toBuilder().normalHardTtlMs(hardTtlMs).normalSoftTtlMs(softTtlMs).build();
8✔
507
  }
508

509
  /**
510
   * Create a new {@link CacheEntry} with updated TTL fields, preserving all
511
   * other metadata from the supplied original entry.
512
   *
513
   * <p>Sets {@code hardTtlMs}, {@code softTtlMs},
514
   * {@code hardExpireAtMs} (via {@link #computeHardExpireAt}),
515
   * and {@code softExpireAtMs} (via {@link #computeSoftExpireAt}).
516
   *
517
   * @param original   an existing {@link CacheEntry} whose metadata should be preserved;
518
   *                   must not be null
519
   * @param hardTtlMs  hard TTL duration in milliseconds
520
   * @param softTtlMs  soft TTL duration in milliseconds
521
   * @return a new {@link CacheEntry} with the updated TTL timestamps,
522
   *         while keeping all version, state, and normal TTL fields unchanged
523
   */
524
  public CacheEntry applyTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
525
    return original
2✔
526
      .toBuilder()
2✔
527
      .hardTtlMs(hardTtlMs)
2✔
528
      .softTtlMs(softTtlMs)
3✔
529
      .hardExpireAtMs(computeHardExpireAt(hardTtlMs))
4✔
530
      .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
531
      .build();
1✔
532
  }
533

534
  /**
535
   * Create a copy of the entry with only the hard TTL updated, leaving the
536
   * existing soft TTL and all version/state fields untouched.
537
   *
538
   * @param original   the source {@link CacheEntry} to copy
539
   * @param hardTtlMs  hard TTL duration in milliseconds
540
   * @return a new {@link CacheEntry} with the updated hard TTL and expiration
541
   */
542
  public CacheEntry applyHardTtl(CacheEntry original, long hardTtlMs) {
543
    return original.toBuilder().hardTtlMs(hardTtlMs).hardExpireAtMs(computeHardExpireAt(hardTtlMs)).build();
10✔
544
  }
545

546
  /**
547
   * Create a copy of the entry with only the soft TTL updated, leaving the
548
   * existing hard TTL and all version/state fields untouched.
549
   *
550
   * @param original   the source {@link CacheEntry} to copy
551
   * @param softTtlMs  soft TTL duration in milliseconds
552
   * @return a new {@link CacheEntry} with the updated soft TTL and expiration
553
   */
554
  public CacheEntry applySoftTtl(CacheEntry original, long softTtlMs) {
555
    return original.toBuilder().softTtlMs(softTtlMs).softExpireAtMs(computeSoftExpireAt(softTtlMs)).build();
10✔
556
  }
557

558
  /**
559
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
560
   * using the configured default jitter ratio.
561
   * Propagates {@link Long#MAX_VALUE} unchanged — used to signal permanent entries
562
   * (pure logical expiry with no hard TTL eviction).
563
   */
564
  public long toHardExpireTimestamp(long hardTtlMs) {
565
    return toHardExpireTimestamp(hardTtlMs, defaultTtlJitterRatio);
6✔
566
  }
567

568
  /**
569
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
570
   * using the given jitter ratio instead of the configured default.
571
   * Propagates {@link Long#MAX_VALUE} unchanged.
572
   *
573
   * @param hardTtlMs      the hard TTL duration in milliseconds
574
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
575
   * @return absolute epoch-ms timestamp for hard expiry
576
   */
577
  public long toHardExpireTimestamp(long hardTtlMs, double ttlJitterRatio) {
578
    if (hardTtlMs == Long.MAX_VALUE) {
4✔
579
      return Long.MAX_VALUE;
2✔
580
    }
581
    long jitter = DelayUtil.computeTtlJitter(hardTtlMs, ttlJitterRatio);
4✔
582

583
    return hardTtlMs > 0 ? TimeSource.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
584
  }
585

586
  /**
587
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp.
588
   * Applies configurable jitter (default ±5%) to prevent cache stampedes.
589
   * Returns 0 if soft expire is disabled or the TTL is non-positive.
590
   *
591
   * @param softTtlMs the soft TTL duration in milliseconds
592
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
593
   */
594
  public long toSoftExpireTimestamp(long softTtlMs) {
595
    return toSoftExpireTimestamp(softTtlMs, defaultTtlJitterRatio);
6✔
596
  }
597

598
  /**
599
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp
600
   * using the given jitter ratio instead of the configured default.
601
   * Returns 0 if soft expire is disabled. Propagates {@link Long#MAX_VALUE} unchanged.
602
   *
603
   * @param softTtlMs      the soft TTL duration in milliseconds
604
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
605
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
606
   */
607
  public long toSoftExpireTimestamp(long softTtlMs, double ttlJitterRatio) {
608
    if (!isSoftExpireEnabled() || softTtlMs <= 0) {
7✔
609
      return 0L;
2✔
610
    }
611
    if (softTtlMs == Long.MAX_VALUE) {
4✔
612
      return Long.MAX_VALUE;
2✔
613
    }
614
    long jitter = DelayUtil.computeTtlJitter(softTtlMs, ttlJitterRatio);
4✔
615

616
    return TimeSource.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
617
  }
618

619
  /**
620
   * Check whether the given key's soft TTL has expired.
621
   *
622
   * @return {@code true} if the entry's soft TTL has expired or the entry is absent
623
   * @throws IllegalStateException if soft expire is disabled
624
   */
625
  public boolean isSoftExpired(Object cacheEntry) {
626
    if (!isSoftExpireEnabled()) {
3✔
627
      throw new IllegalStateException(
5✔
628
        "CacheExpireManager soft expire is disabled, isSoftExpired() should not be called"
629
      );
630
    }
631
    if (cacheEntry instanceof CacheEntry ce) {
6✔
632
      long expireAt = ce.getSoftExpireAtMs();
3✔
633
      return expireAt <= 0 || expireAt < TimeSource.currentTimeMillis();
12✔
634
    }
635
    return true;
2✔
636
  }
637

638
  /**
639
   * Triggers an asynchronous background refresh for the given cache key if the
640
   * current entry has reached its soft expiry threshold. The caller (typically
641
   * {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire HotKeyCache.getWithSoftExpire}) has already returned the stale value to the
642
   * client, so this method executes entirely in the background without blocking
643
   * the caller.
644
   *
645
   * <p><b>Concurrency:</b> uses {@link ConcurrentHashMap#compute} on
646
   * {@code pendingRefreshes} to atomically decide whether a new refresh task
647
   * should be launched. This eliminates the earlier DCL (double-checked locking)
648
   * pattern that required manual clean-up of a placeholder
649
   * {@link CompletableFuture} when the limiter rejected the task or the executor
650
   * was saturated.
651
   *
652
   * @param cacheKey  the key whose value should be refreshed
653
   * @param reader    the data-source supplier
654
   * @param softTtlMs the soft TTL to set on the refreshed entry (milliseconds)
655
   */
656
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
657
    if (!isSoftExpireEnabled()) {
3✔
658
      return;
1✔
659
    }
660
    pendingRefreshes.compute(cacheKey, (k, existing) -> {
10✔
661
      // If there is already an in-flight refresh for this key, keep the
662
      // existing future and do nothing.
663
      if (existing != null && !existing.isDone()) {
5!
664
        return existing;
2✔
665
      }
666

667
      // Try to acquire a permit from the global refresh limiter.
668
      if (!refreshLimiter.tryAcquire()) {
4✔
669
        log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
670
        // Returning null removes any previous entry, leaving no stale marker.
671
        return null;
2✔
672
      }
673

674
      // Snapshot the current data version so that we can detect whether a
675
      // newer write has superseded the cache entry while this refresh was
676
      // in-flight.
677
      long refreshStartDataVersion = Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
7✔
678
        .filter(CacheEntry.class::isInstance)
6✔
679
        .map(CacheEntry.class::cast)
5✔
680
        .map(CacheEntry::getDataVersion)
2✔
681
        .orElse(VERSION_DEFAULT);
5✔
682

683
      // Build the async refresh task with timeout protection.
684
      CompletableFuture<?> task;
685
      try {
686
        task = CompletableFuture.supplyAsync(reader, executor).orTimeout(refreshTimeoutSeconds, TimeUnit.SECONDS);
8✔
687
      } catch (RejectedExecutionException e) {
1✔
688
        // Executor saturated – release the limiter permit and leave no
689
        // pending marker so the next read can retry immediately.
690
        refreshLimiter.release();
3✔
691
        log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
692
        return null;
2✔
693
      }
1✔
694

695
      // When the refresh completes (success, failure or timeout), update the
696
      // cache entry if the value is still applicable, and always release the
697
      // resources.
698
      task.whenComplete((value, error) -> {
8✔
699
        try {
700
          if (error != null) {
2✔
701
            if (error instanceof TimeoutException) {
3!
702
              log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
703
            } else {
704
              log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
705
            }
706
            return;
1✔
707
          }
708
          if (value != null) {
2✔
709
            caffeineCache
2✔
710
              .asMap()
8✔
711
              .compute(cacheKey, (key, existingEntry) ->
2✔
712
                Optional.ofNullable(existingEntry)
5✔
713
                  .filter(CacheEntry.class::isInstance)
6✔
714
                  .map(CacheEntry.class::cast)
10✔
715
                  .map(entry -> {
5✔
716
                    // Version guard: if a newer write has
717
                    // arrived while we were refreshing,
718
                    // discard the stale refresh result.
719
                    if (entry.getDataVersion() > refreshStartDataVersion) {
5✔
720
                      log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
721
                      return entry;
2✔
722
                    }
723
                    return applySoftTtl(entry.toBuilder().value(value).build(), softTtlMs);
9✔
724
                  })
725
                  .orElseGet(() ->
1✔
726
                    createBuilder(
12✔
727
                      value,
728
                      VERSION_DEFAULT,
729
                      false,
730
                      VERSION_DEFAULT,
731
                      0L,
732
                      softTtlMs,
733
                      Long.MAX_VALUE,
734
                      computeSoftExpireAt(softTtlMs),
4✔
735
                      0L,
736
                      0L,
737
                      KeyState.NORMAL
738
                    )
739
                  )
740
              );
741
          }
742
        } finally {
743
          // Always release the limiter permit and remove the in-flight
744
          // marker so that a future refresh can be scheduled.
745
          refreshLimiter.release();
3✔
746
          pendingRefreshes.remove(cacheKey);
5✔
747
        }
748
      });
1✔
749

750
      // Return the new task; it will be stored in the map and visible to
751
      // any concurrent caller for this key.
752
      return task;
2✔
753
    });
754
  }
1✔
755

756
  /**
757
   * Extend both the hard and soft expiry for a cache entry.
758
   *
759
   * <p>If the caller passes {@code 0} for either TTL, the configured default
760
   * hot TTL ({@link #getEffectiveHotHardTtlMs()} / {@link #getEffectiveHotSoftTtlMs()})
761
   * is used.
762
   *
763
   * @param cacheKey  the key whose expiry should be extended
764
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
765
   *                  configured hot hard TTL
766
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
767
   *                  configured hot soft TTL
768
   */
769
  public void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs) {
770
    long hard = resolveEffectiveHotHard(hardTtlMs);
4✔
771
    long soft = resolveEffectiveHotSoft(softTtlMs);
4✔
772
    extendExpiry(cacheKey, hard, soft, true, true);
7✔
773
  }
1✔
774

775
  /**
776
   * Extend only the hard expiry for a cache entry, leaving the soft expiry
777
   * unchanged. Useful when promoting a NORMAL or COOL entry to HOT — the
778
   * hard TTL must be lengthened to the hot‑key value, but the existing soft
779
   * expiry (if any) should be preserved because it reflects a more recent
780
   * refresh cycle.
781
   *
782
   * <p>If the caller passes {@code 0} the configured default hot hard TTL
783
   * ({@link #getEffectiveHotHardTtlMs()}) is used.
784
   *
785
   * @param cacheKey  the key whose hard expiry should be extended
786
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
787
   *                  configured hot hard TTL
788
   */
789
  public void extendHardExpiry(String cacheKey, long hardTtlMs) {
790
    long hard = resolveEffectiveHotHard(hardTtlMs);
×
791
    extendExpiry(cacheKey, hard, 0, true, false);
×
792
  }
×
793

794
  /**
795
   * Extend only the soft expiry for a cache entry, leaving the hard expiry
796
   * unchanged. Useful when a background refresh has completed and the caller
797
   * wants to reset the soft TTL without affecting the hard TTL.
798
   *
799
   * <p>If the caller passes {@code 0} the configured default hot soft TTL
800
   * ({@link #getEffectiveHotSoftTtlMs()}) is used.
801
   *
802
   * @param cacheKey  the key whose soft expiry should be extended
803
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
804
   *                  configured hot soft TTL
805
   */
806
  public void extendSoftExpiry(String cacheKey, long softTtlMs) {
807
    long soft = resolveEffectiveHotSoft(softTtlMs);
×
808
    extendExpiry(cacheKey, 0, soft, false, true);
×
809
  }
×
810

811
  /**
812
   * Atomically update the expiry timestamps of an existing cache entry.
813
   *
814
   * @param cacheKey    the key whose expiry should be extended
815
   * @param hardTtlMs   new hard TTL in milliseconds (ignored if {@code updateHard} is false)
816
   * @param softTtlMs   new soft TTL in milliseconds (ignored if {@code updateSoft} is false)
817
   * @param updateHard  whether to update the hard expiry timestamp
818
   * @param updateSoft  whether to update the soft expiry timestamp
819
   */
820
  private void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs, boolean updateHard, boolean updateSoft) {
821
    caffeineCache
2✔
822
      .asMap()
8✔
823
      .computeIfPresent(cacheKey, (k, existing) -> {
2✔
824
        if (existing instanceof CacheEntry entry) {
6!
825
          if (updateHard) {
2!
826
            entry = applyHardTtl(entry, resolveEffectiveHardTtl(hardTtlMs));
7✔
827
          }
828
          if (updateSoft) {
2!
829
            entry = applySoftTtl(entry, resolveEffectiveSoftTtl(softTtlMs));
7✔
830
          }
831
          return entry;
2✔
832
        }
833
        return existing;
×
834
      });
835
  }
1✔
836
}
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