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

Hyshmily / hotkey / 28735434673

05 Jul 2026 08:55AM UTC coverage: 90.421% (+0.02%) from 90.4%
28735434673

push

github

Hyshmily
perf : simplify the code

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

1295 of 1498 branches covered (86.45%)

Branch coverage included in aggregate %.

121 of 134 new or added lines in 11 files covered. (90.3%)

2 existing lines in 1 file now uncovered.

3689 of 4014 relevant lines covered (91.9%)

4.19 hits per line

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

91.08
/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 record EntrySnapshot(
18✔
61
    long dataVersion,
62
    long decisionVersion,
63
    String decisionNodeId,
64
    long decisionEpoch,
65
    KeyState keyState
66
  ) {
67
    static final EntrySnapshot DEFAULT = new EntrySnapshot(VERSION_DEFAULT, VERSION_DEFAULT, null, 0L, KeyState.NORMAL);
10✔
68
  }
69

70
  private static EntrySnapshot snapshotEntry(Object raw) {
71
    if (raw instanceof CacheEntry entry) {
6!
72
      return new EntrySnapshot(
4✔
73
        entry.getDataVersion(),
2✔
74
        entry.getDecisionVersion(),
2✔
75
        entry.getDecisionNodeId(),
2✔
76
        entry.getDecisionEpoch(),
2✔
77
        entry.getKeyState()
2✔
78
      );
79
    }
NEW
80
    return EntrySnapshot.DEFAULT;
×
81
  }
82

83
  /**
84
   * Creates a CacheExpireManager with the given Caffeine cache, executor, and TTL config.
85
   *
86
   * @param caffeineCache   the underlying L1 Caffeine cache
87
   * @param executor        async executor for background refresh
88
   * @param ttlConfig       TTL configuration (normal and hot-key variants)
89
   * @param refreshMaxPools maximum concurrent background refreshes (capped at 100)
90
   */
91
  public CacheExpireManager(
92
    Cache<String, Object> caffeineCache,
93
    Executor executor,
94
    HotKeyProperties ttlConfig,
95
    int refreshMaxPools
96
  ) {
2✔
97
    this.caffeineCache = caffeineCache;
3✔
98
    this.executor = executor;
3✔
99
    this.ttlConfig = ttlConfig;
3✔
100
    this.refreshLimiter = ttlConfig.isSoftExpireEnabled()
4✔
101
      ? new Semaphore(refreshMaxPools > 0 ? refreshMaxPools : 100)
8!
102
      : null;
2✔
103
    this.defaultTtlJitterRatio = ttlConfig.getTtlJitterRatio();
4✔
104
  }
1✔
105

106
  /**
107
   * Create a CacheExpireManager with explicit jitter ratio (for testing).
108
   */
109
  CacheExpireManager(
110
    Cache<String, Object> caffeineCache,
111
    Executor executor,
112
    HotKeyProperties ttlConfig,
113
    int refreshMaxPools,
114
    double defaultTtlJitterRatio
115
  ) {
×
116
    this.caffeineCache = caffeineCache;
×
117
    this.executor = executor;
×
118
    this.ttlConfig = ttlConfig;
×
119
    this.refreshLimiter = ttlConfig.isSoftExpireEnabled()
×
120
      ? new Semaphore(refreshMaxPools > 0 ? refreshMaxPools : 100)
×
121
      : null;
×
122
    this.defaultTtlJitterRatio = defaultTtlJitterRatio;
×
123
  }
×
124

125
  /**
126
   * Whether any soft TTL is configured (normal or hot).
127
   *
128
   * @return {@code true} if soft expire is enabled in the configuration
129
   */
130
  public boolean isSoftExpireEnabled() {
131
    return ttlConfig.isSoftExpireEnabled();
4✔
132
  }
133

134
  /**
135
   * Check whether a {@link CacheEntry} has logically expired based on its
136
   * {@code hardExpireAtMs}.  Entries with {@code hardExpireAtMs == Long.MAX_VALUE}
137
   * are treated as permanent (never logically expire).
138
   *
139
   * @param entry the cache entry to inspect
140
   * @return {@code true} if the entry has logically expired
141
   */
142
  public boolean isLogicallyExpired(CacheEntry entry) {
143
    return entry.getHardExpireAtMs() != Long.MAX_VALUE && TimeSource.currentTimeMillis() >= entry.getHardExpireAtMs();
14✔
144
  }
145

146
  /**
147
   * Check whether the given raw cache value is a logically expired {@link CacheEntry}
148
   * and, if so, invalidate it and return {@code true}.
149
   * <p>Eliminates code duplication between {@link io.github.hyshmily.hotkey.cache.HotKeyCache#get}
150
   * and {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire},
151
   * which both perform this check before and after side effects (TOCTOU guard).
152
   *
153
   * @param cacheKey the cache key to invalidate if expired
154
   * @param raw      the raw value from the Caffeine cache
155
   * @return {@code true} if the entry was expired and has been invalidated
156
   */
157
  public boolean invalidateIfIsLogicallyExpired(String cacheKey, Object raw) {
158
    if (raw instanceof CacheEntry ce && isLogicallyExpired(ce)) {
10✔
159
      caffeineCache.invalidate(cacheKey);
4✔
160
      log.debug("Cache entry logically expired during processing, reloading: {}", cacheKey);
4✔
161
      return true;
2✔
162
    }
163
    return false;
2✔
164
  }
165

166
  public long computeNullExpireAt(long nullTtlMs) {
167
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
10✔
168
    return toHardExpireTimestamp(effective);
4✔
169
  }
170

171
  /**
172
   * Hard expire timestamp from an explicit TTL duration.
173
   * Falls back to the normal-key default if {@code hardTtlMs <= 0}.
174
   *
175
   * @param hardTtlMs the hard TTL duration in milliseconds (&lt;= 0 uses configured default)
176
   * @return absolute epoch-ms timestamp for hard expiry
177
   */
178
  public long computeHardExpireAt(long hardTtlMs) {
179
    return toHardExpireTimestamp(resolveEffectiveHardTtl(hardTtlMs));
6✔
180
  }
181

182
  /**
183
   * Hard expire timestamp for hot keys, using {@code default-hot-hard-ttl} / {@code hot-hard-ttl}.
184
   * Returns {@code Long.MAX_VALUE} if hot hard expire is disabled (TTL &lt;= 0).
185
   *
186
   * @return absolute epoch-ms timestamp for hot-key hard expiry
187
   */
188
  public long computeHotHardExpireAt() {
189
    return toHardExpireTimestamp(ttlConfig.effectiveHotHardTtlMs());
6✔
190
  }
191

192
  /**
193
   * Soft expire timestamp for hot keys, using {@code default-hot-soft-ttl} / {@code hot-soft-ttl}.
194
   *
195
   * @return absolute epoch-ms timestamp for hot-key soft expiry, or 0 if disabled
196
   */
197
  public long computeHotSoftExpireAt() {
198
    return toSoftExpireTimestamp(ttlConfig.effectiveHotSoftTtlMs());
6✔
199
  }
200

201
  /**
202
   * Soft expire timestamp from an explicit TTL duration.
203
   * Falls back to the normal-key default if {@code softTtlMs <= 0}. Returns 0 if soft expire is disabled.
204
   *
205
   * @param softTtlMs the soft TTL duration in milliseconds (&lt;= 0 uses configured default)
206
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
207
   */
208
  public long computeSoftExpireAt(long softTtlMs) {
209
    if (!isSoftExpireEnabled()) {
3✔
210
      return 0L;
2✔
211
    }
212
    return toSoftExpireTimestamp(resolveEffectiveSoftTtl(softTtlMs));
6✔
213
  }
214

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

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

235
  /**
236
   * Effective hard TTL for hot keys (override > default).
237
   *
238
   * @return effective hot hard TTL duration in milliseconds
239
   */
240
  public long getEffectiveHotHardTtlMs() {
241
    return ttlConfig.effectiveHotHardTtlMs();
4✔
242
  }
243

244
  /**
245
   * Resolve effective hard TTL for hot keys: use the override value if
246
   * positive, otherwise fall back to the configured hot-key hard TTL.
247
   *
248
   * @param hardTtlMs hard TTL override ({@code 0} or negative uses default)
249
   * @return effective hot-key hard TTL duration in milliseconds
250
   */
251
  public long resolveEffectiveHotHard(long hardTtlMs) {
252
    return hardTtlMs > 0 ? hardTtlMs : getEffectiveHotHardTtlMs();
9✔
253
  }
254

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

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

275
  /**
276
   * Effective soft TTL for hot keys (override > default).
277
   *
278
   * @return effective hot soft TTL duration in milliseconds
279
   */
280
  public long getEffectiveHotSoftTtlMs() {
281
    return ttlConfig.effectiveHotSoftTtlMs();
4✔
282
  }
283

284
  /**
285
   * Resolve effective soft TTL for hot keys: use the override value if
286
   * positive, otherwise fall back to the configured hot-key soft TTL.
287
   *
288
   * @param softTtlMs soft TTL override ({@code 0} or negative uses default)
289
   * @return effective hot-key soft TTL duration in milliseconds
290
   */
291
  public long resolveEffectiveHotSoft(long softTtlMs) {
292
    return softTtlMs > 0 ? softTtlMs : getEffectiveHotSoftTtlMs();
9✔
293
  }
294

295
  /**
296
   * Build a {@link CacheEntry} from fully resolved fields, including
297
   * decision metadata (node, epoch), pre-computed expire timestamps,
298
   * and normal-TTL values. Normal TTL is applied via
299
   * {@link #applyNormalTtl} after construction.
300
   * <p>
301
   * This overload accepts pre-computed hard/soft expire timestamps,
302
   * which is useful when the caller already knows the exact expiry
303
   * baseline (e.g., when copying from an existing entry).
304
   *
305
   * @param value              the cached value
306
   * @param dataVersion        the data version for cross-instance sync
307
   * @param isVersionDegraded  whether the data version is degraded (local fallback)
308
   * @param decisionVersion    the Worker decision version
309
   * @param decisionNodeId     the Worker node ID that produced the decision
310
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
311
   * @param hardTtlMs          hard TTL duration in milliseconds
312
   * @param softTtlMs          soft TTL duration in milliseconds
313
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
314
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
315
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
316
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
317
   * @param keyState           the initial key state (NORMAL, HOT, COOL)
318
   * @return a new {@link CacheEntry} with all fields set
319
   */
320
  public CacheEntry createBuilder(
321
    Object value,
322
    long dataVersion,
323
    boolean isVersionDegraded,
324
    long decisionVersion,
325
    String decisionNodeId,
326
    long decisionEpoch,
327
    long hardTtlMs,
328
    long softTtlMs,
329
    long hardExpireAtMs,
330
    long softExpireAtMs,
331
    long normalHardTtlMs,
332
    long normalSoftTtlMs,
333
    KeyState keyState
334
  ) {
335
    return applyNormalTtl(
3✔
336
      CacheEntry.builder()
2✔
337
        .value(value)
2✔
338
        .dataVersion(dataVersion)
2✔
339
        .isVersionDegraded(isVersionDegraded)
2✔
340
        .decisionVersion(decisionVersion)
2✔
341
        .decisionNodeId(decisionNodeId)
2✔
342
        .decisionEpoch(decisionEpoch)
2✔
343
        .hardTtlMs(hardTtlMs)
2✔
344
        .softTtlMs(softTtlMs)
2✔
345
        .hardExpireAtMs(hardExpireAtMs)
2✔
346
        .softExpireAtMs(softExpireAtMs)
2✔
347
        .keyState(keyState)
1✔
348
        .build(),
3✔
349
      normalHardTtlMs,
350
      normalSoftTtlMs
351
    );
352
  }
353

354
  /**
355
   * Build a {@link CacheEntry} from fully resolved fields with decision
356
   * metadata but without pre-computed expire timestamps. Expire timestamps
357
   * are computed automatically via {@link #applyTtl}.
358
   * <p>
359
   * The {@code hardTtlMs} and {@code softTtlMs} passed here are used both
360
   * as field values <em>and</em> as inputs to {@code applyTtl}, which
361
   * overwrites the expire-at timestamps. This is the typical path for
362
   * entries sourced from a remote reader (Worker or Redis) where the
363
   * caller does not pre-compute timestamps.
364
   *
365
   * @param value              the cached value
366
   * @param dataVersion        the data version for cross-instance sync
367
   * @param isVersionDegraded  whether the data version is degraded
368
   * @param decisionVersion    the Worker decision version
369
   * @param decisionNodeId     the Worker node ID that produced the decision
370
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
371
   * @param hardTtlMs          hard TTL duration in milliseconds
372
   * @param softTtlMs          soft TTL duration in milliseconds
373
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
374
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
375
   * @param keyState           the initial key state
376
   * @return a new {@link CacheEntry} with expire timestamps computed
377
   */
378
  public CacheEntry createBuilder(
379
    Object value,
380
    long dataVersion,
381
    boolean isVersionDegraded,
382
    long decisionVersion,
383
    String decisionNodeId,
384
    long decisionEpoch,
385
    long hardTtlMs,
386
    long softTtlMs,
387
    long normalHardTtlMs,
388
    long normalSoftTtlMs,
389
    KeyState keyState
390
  ) {
391
    return applyTtl(
4✔
392
      applyNormalTtl(
3✔
393
        CacheEntry.builder()
2✔
394
          .value(value)
2✔
395
          .dataVersion(dataVersion)
2✔
396
          .isVersionDegraded(isVersionDegraded)
2✔
397
          .decisionVersion(decisionVersion)
2✔
398
          .decisionNodeId(decisionNodeId)
2✔
399
          .decisionEpoch(decisionEpoch)
2✔
400
          .hardTtlMs(hardTtlMs)
2✔
401
          .softTtlMs(softTtlMs)
2✔
402
          .keyState(keyState)
1✔
403
          .build(),
3✔
404
        normalHardTtlMs,
405
        normalSoftTtlMs
406
      ),
407
      hardTtlMs,
408
      softTtlMs
409
    );
410
  }
411

412
  /**
413
   * Build a {@link CacheEntry} with pre-computed expire timestamps
414
   * but without decision node/epoch metadata. Normal TTL is applied
415
   * via {@link #applyNormalTtl} after construction.
416
   * <p>
417
   * This overload omits {@code decisionNodeId} and {@code decisionEpoch},
418
   * which is appropriate for entries created by local promotion (no
419
   * Worker origin). The expire timestamps are caller-supplied.
420
   *
421
   * @param value              the cached value
422
   * @param dataVersion        the data version for cross-instance sync
423
   * @param isVersionDegraded  whether the data version is degraded
424
   * @param decisionVersion    the Worker decision version (0 for local)
425
   * @param hardTtlMs          hard TTL duration in milliseconds
426
   * @param softTtlMs          soft TTL duration in milliseconds
427
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
428
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
429
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
430
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
431
   * @param keyState           the initial key state
432
   * @return a new {@link CacheEntry} with all fields set
433
   */
434
  public CacheEntry createBuilder(
435
    Object value,
436
    long dataVersion,
437
    boolean isVersionDegraded,
438
    long decisionVersion,
439
    long hardTtlMs,
440
    long softTtlMs,
441
    long hardExpireAtMs,
442
    long softExpireAtMs,
443
    long normalHardTtlMs,
444
    long normalSoftTtlMs,
445
    KeyState keyState
446
  ) {
447
    return applyNormalTtl(
3✔
448
      CacheEntry.builder()
2✔
449
        .value(value)
2✔
450
        .dataVersion(dataVersion)
2✔
451
        .isVersionDegraded(isVersionDegraded)
2✔
452
        .decisionVersion(decisionVersion)
2✔
453
        .hardTtlMs(hardTtlMs)
2✔
454
        .softTtlMs(softTtlMs)
2✔
455
        .hardExpireAtMs(hardExpireAtMs)
2✔
456
        .softExpireAtMs(softExpireAtMs)
2✔
457
        .keyState(keyState)
1✔
458
        .build(),
3✔
459
      normalHardTtlMs,
460
      normalSoftTtlMs
461
    );
462
  }
463

464
  /**
465
   * Build a {@link CacheEntry} from raw fields without pre-computed
466
   * expire timestamps or decision metadata. Expire timestamps are
467
   * computed via {@link #applyTtl} after normal TTL is set.
468
   * <p>
469
   * This is the most compact overload, suitable for local promotions
470
   * where the caller has no Worker decision context and wants
471
   * timestamps computed automatically.
472
   *
473
   * @param value              the cached value
474
   * @param dataVersion        the data version for cross-instance sync
475
   * @param isVersionDegraded  whether the data version is degraded
476
   * @param decisionVersion    the Worker decision version (0 for local)
477
   * @param hardTtlMs          hard TTL duration in milliseconds
478
   * @param softTtlMs          soft TTL duration in milliseconds
479
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
480
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
481
   * @param keyState           the initial key state
482
   * @return a new {@link CacheEntry} with expire timestamps computed
483
   */
484
  public CacheEntry createBuilder(
485
    Object value,
486
    long dataVersion,
487
    boolean isVersionDegraded,
488
    long decisionVersion,
489
    long hardTtlMs,
490
    long softTtlMs,
491
    long normalHardTtlMs,
492
    long normalSoftTtlMs,
493
    KeyState keyState
494
  ) {
495
    return applyTtl(
4✔
496
      applyNormalTtl(
3✔
497
        CacheEntry.builder()
2✔
498
          .value(value)
2✔
499
          .dataVersion(dataVersion)
2✔
500
          .isVersionDegraded(isVersionDegraded)
2✔
501
          .decisionVersion(decisionVersion)
2✔
502
          .keyState(keyState)
1✔
503
          .build(),
3✔
504
        normalHardTtlMs,
505
        normalSoftTtlMs
506
      ),
507
      hardTtlMs,
508
      softTtlMs
509
    );
510
  }
511

512
  /**
513
   * Create a copy of the entry with the normal (non-hot) TTL values set,
514
   * leaving all other fields (hot TTLs, versions, state) untouched.
515
   * <p>
516
   * The normal TTLs ({@code normalHardTtlMs}, {@code normalSoftTtlMs})
517
   * are the baseline TTL values that the entry reverts to when its key
518
   * state transitions from HOT back to NORMAL. These are recorded at
519
   * entry creation and preserved across state transitions.
520
   *
521
   * @param original   the source {@link CacheEntry} to copy
522
   * @param hardTtlMs  normal hard TTL duration in milliseconds
523
   * @param softTtlMs  normal soft TTL duration in milliseconds
524
   * @return a new {@link CacheEntry} with the normal TTL fields updated
525
   */
526
  public CacheEntry applyNormalTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
527
    return original.toBuilder().normalHardTtlMs(hardTtlMs).normalSoftTtlMs(softTtlMs).build();
8✔
528
  }
529

530
  /**
531
   * Create a new {@link CacheEntry} with updated TTL fields, preserving all
532
   * other metadata from the supplied original entry.
533
   *
534
   * <p>Sets {@code hardTtlMs}, {@code softTtlMs},
535
   * {@code hardExpireAtMs} (via {@link #computeHardExpireAt}),
536
   * and {@code softExpireAtMs} (via {@link #computeSoftExpireAt}).
537
   *
538
   * @param original   an existing {@link CacheEntry} whose metadata should be preserved;
539
   *                   must not be null
540
   * @param hardTtlMs  hard TTL duration in milliseconds
541
   * @param softTtlMs  soft TTL duration in milliseconds
542
   * @return a new {@link CacheEntry} with the updated TTL timestamps,
543
   *         while keeping all version, state, and normal TTL fields unchanged
544
   */
545
  public CacheEntry applyTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
546
    return original
2✔
547
      .toBuilder()
2✔
548
      .hardTtlMs(hardTtlMs)
2✔
549
      .softTtlMs(softTtlMs)
3✔
550
      .hardExpireAtMs(computeHardExpireAt(hardTtlMs))
4✔
551
      .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
552
      .build();
1✔
553
  }
554

555
  /**
556
   * Create a copy of the entry with only the hard TTL updated, leaving the
557
   * existing soft TTL and all version/state fields untouched.
558
   *
559
   * @param original   the source {@link CacheEntry} to copy
560
   * @param hardTtlMs  hard TTL duration in milliseconds
561
   * @return a new {@link CacheEntry} with the updated hard TTL and expiration
562
   */
563
  public CacheEntry applyHardTtl(CacheEntry original, long hardTtlMs) {
564
    return original.toBuilder().hardTtlMs(hardTtlMs).hardExpireAtMs(computeHardExpireAt(hardTtlMs)).build();
10✔
565
  }
566

567
  /**
568
   * Create a copy of the entry with only the soft TTL updated, leaving the
569
   * existing hard TTL and all version/state fields untouched.
570
   *
571
   * @param original   the source {@link CacheEntry} to copy
572
   * @param softTtlMs  soft TTL duration in milliseconds
573
   * @return a new {@link CacheEntry} with the updated soft TTL and expiration
574
   */
575
  public CacheEntry applySoftTtl(CacheEntry original, long softTtlMs) {
576
    return original.toBuilder().softTtlMs(softTtlMs).softExpireAtMs(computeSoftExpireAt(softTtlMs)).build();
10✔
577
  }
578

579
  /**
580
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
581
   * using the configured default jitter ratio.
582
   * Propagates {@link Long#MAX_VALUE} unchanged — used to signal permanent entries
583
   * (pure logical expiry with no hard TTL eviction).
584
   */
585
  public long toHardExpireTimestamp(long hardTtlMs) {
586
    return toHardExpireTimestamp(hardTtlMs, defaultTtlJitterRatio);
6✔
587
  }
588

589
  /**
590
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
591
   * using the given jitter ratio instead of the configured default.
592
   * Propagates {@link Long#MAX_VALUE} unchanged.
593
   *
594
   * @param hardTtlMs      the hard TTL duration in milliseconds
595
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
596
   * @return absolute epoch-ms timestamp for hard expiry
597
   */
598
  public long toHardExpireTimestamp(long hardTtlMs, double ttlJitterRatio) {
599
    if (hardTtlMs == Long.MAX_VALUE) {
4✔
600
      return Long.MAX_VALUE;
2✔
601
    }
602
    long jitter = DelayUtil.computeTtlJitter(hardTtlMs, ttlJitterRatio);
4✔
603

604
    return hardTtlMs > 0 ? TimeSource.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
605
  }
606

607
  /**
608
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp.
609
   * Applies configurable jitter (default ±5%) to prevent cache stampedes.
610
   * Returns 0 if soft expire is disabled or the TTL is non-positive.
611
   *
612
   * @param softTtlMs the soft TTL duration in milliseconds
613
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
614
   */
615
  public long toSoftExpireTimestamp(long softTtlMs) {
616
    return toSoftExpireTimestamp(softTtlMs, defaultTtlJitterRatio);
6✔
617
  }
618

619
  /**
620
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp
621
   * using the given jitter ratio instead of the configured default.
622
   * Returns 0 if soft expire is disabled. Propagates {@link Long#MAX_VALUE} unchanged.
623
   *
624
   * @param softTtlMs      the soft TTL duration in milliseconds
625
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
626
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
627
   */
628
  public long toSoftExpireTimestamp(long softTtlMs, double ttlJitterRatio) {
629
    if (!isSoftExpireEnabled() || softTtlMs <= 0) {
7✔
630
      return 0L;
2✔
631
    }
632
    if (softTtlMs == Long.MAX_VALUE) {
4✔
633
      return Long.MAX_VALUE;
2✔
634
    }
635
    long jitter = DelayUtil.computeTtlJitter(softTtlMs, ttlJitterRatio);
4✔
636

637
    return TimeSource.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
638
  }
639

640
  /**
641
   * Check whether the given key's soft TTL has expired.
642
   *
643
   * @return {@code true} if the entry's soft TTL has expired or the entry is absent
644
   * @throws IllegalStateException if soft expire is disabled
645
   */
646
  public boolean isSoftExpired(Object cacheEntry) {
647
    if (!isSoftExpireEnabled()) {
3✔
648
      throw new IllegalStateException(
5✔
649
        "CacheExpireManager soft expire is disabled, isSoftExpired() should not be called"
650
      );
651
    }
652
    if (cacheEntry instanceof CacheEntry ce) {
6✔
653
      long expireAt = ce.getSoftExpireAtMs();
3✔
654
      return expireAt <= 0 || expireAt < TimeSource.currentTimeMillis();
12✔
655
    }
656
    return true;
2✔
657
  }
658

659
  /**
660
   * Triggers an asynchronous background refresh for the given cache key if the
661
   * current entry has reached its soft expiry threshold. The caller (typically
662
   * {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire HotKeyCache.getWithSoftExpire}) has already returned the stale value to the
663
   * client, so this method executes entirely in the background without blocking
664
   * the caller.
665
   *
666
   * <p><b>Concurrency:</b> uses {@link ConcurrentHashMap#compute} on
667
   * {@code pendingRefreshes} to atomically decide whether a new refresh task
668
   * should be launched. This eliminates the earlier DCL (double-checked locking)
669
   * pattern that required manual clean-up of a placeholder
670
   * {@link CompletableFuture} when the limiter rejected the task or the executor
671
   * was saturated.
672
   *
673
   * @param cacheKey  the key whose value should be refreshed
674
   * @param reader    the data-source supplier
675
   * @param softTtlMs the soft TTL to set on the refreshed entry (milliseconds)
676
   */
677
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
678
    if (!isSoftExpireEnabled()) {
3✔
679
      return;
1✔
680
    }
681
    pendingRefreshes.compute(cacheKey, (k, existing) -> {
10✔
682
      // If there is already an in-flight refresh for this key, keep the
683
      // existing future and do nothing.
684
      if (existing != null && !existing.isDone()) {
5!
685
        return existing;
2✔
686
      }
687

688
      // Try to acquire a permit from the global refresh limiter.
689
      if (!refreshLimiter.tryAcquire()) {
4✔
690
        log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
691
        // Returning null removes any previous entry, leaving no stale marker.
692
        return null;
2✔
693
      }
694

695
      try {
696
        // Snapshot the current entry metadata so we can detect superseding
697
        // writes and preserve Worker decision state across the refresh.
698
        EntrySnapshot snap = snapshotEntry(caffeineCache.getIfPresent(cacheKey));
6✔
699
        final long refreshStartDataVersion = snap.dataVersion();
3✔
700
        final long refreshStartDecisionVersion = snap.decisionVersion();
3✔
701
        final String refreshStartDecisionNodeId = snap.decisionNodeId();
3✔
702
        final long refreshStartDecisionEpoch = snap.decisionEpoch();
3✔
703
        final KeyState refreshStartKeyState = snap.keyState();
3✔
704

705
        // Build the async refresh task with timeout protection.
706
        CompletableFuture<?> task;
707
        try {
708
          task = CompletableFuture.supplyAsync(reader, executor).orTimeout(refreshTimeoutSeconds, TimeUnit.SECONDS);
8✔
709
        } catch (RejectedExecutionException e) {
1✔
710
          // Executor saturated – release the limiter permit and leave no
711
          // pending marker so the next read can retry immediately.
712
          refreshLimiter.release();
3✔
713
          log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
714
          return null;
2✔
715
        }
1✔
716

717
        // When the refresh completes (success, failure or timeout), update the
718
        // cache entry if the value is still applicable, and always release the
719
        // resources.
720
        task.whenComplete((value, error) -> {
12✔
721
          try {
722
            if (error != null) {
2✔
723
              if (error instanceof TimeoutException) {
3!
NEW
724
                log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
725
              } else {
726
                log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
727
              }
728
              return;
1✔
729
            }
730
            if (value != null) {
2✔
731
              caffeineCache
2✔
732
                .asMap()
12✔
733
                .compute(cacheKey, (key, existingEntry) ->
2✔
734
                  Optional.ofNullable(existingEntry)
5✔
735
                    .filter(CacheEntry.class::isInstance)
6✔
736
                    .map(CacheEntry.class::cast)
10✔
737
                    .map(entry -> {
9✔
738
                      // Version guard: if a newer write has
739
                      // arrived while we were refreshing,
740
                      // discard the stale refresh result.
741
                      if (entry.getDataVersion() > refreshStartDataVersion) {
5✔
742
                        log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
743
                        return entry;
2✔
744
                      }
745
                      return applySoftTtl(entry.toBuilder().value(value).build(), softTtlMs);
9✔
746
                    })
747
                    .orElseGet(() ->
1✔
748
                      createBuilder(
14✔
749
                        value,
750
                        VERSION_DEFAULT,
751
                        false,
752
                        refreshStartDecisionVersion,
753
                        refreshStartDecisionNodeId,
754
                        refreshStartDecisionEpoch,
755
                        0L,
756
                        softTtlMs,
757
                        Long.MAX_VALUE,
758
                        computeSoftExpireAt(softTtlMs),
4✔
759
                        0L,
760
                        0L,
761
                        refreshStartKeyState
762
                      )
763
                    )
764
                );
765
            }
766
          } finally {
767
            // Always release the limiter permit and remove the in-flight
768
            // marker so that a future refresh can be scheduled.
769
            refreshLimiter.release();
3✔
770
            pendingRefreshes.remove(cacheKey);
5✔
771
          }
772
        });
1✔
773

774
        // Return the new task; it will be stored in the map and visible to
775
        // any concurrent caller for this key.
776
        return task;
2✔
NEW
777
      } catch (Throwable t) {
×
778
        // Unexpected failure before task creation (getIfPresent NPE, supplyAsync Error,
779
        // etc.) — release the semaphore so the refresh limiter does not permanently
780
        // lose a slot. The compute() will not store any entry, so the next read can retry.
NEW
781
        refreshLimiter.release();
×
NEW
782
        throw t;
×
783
      }
784
    });
785
  }
1✔
786

787
  /**
788
   * Extend both the hard and soft expiry for a cache entry.
789
   *
790
   * <p>If the caller passes {@code 0} for either TTL, the configured default
791
   * hot TTL ({@link #getEffectiveHotHardTtlMs()} / {@link #getEffectiveHotSoftTtlMs()})
792
   * is used.
793
   *
794
   * @param cacheKey  the key whose expiry should be extended
795
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
796
   *                  configured hot hard TTL
797
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
798
   *                  configured hot soft TTL
799
   */
800
  public void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs) {
801
    long hard = resolveEffectiveHotHard(hardTtlMs);
4✔
802
    long soft = resolveEffectiveHotSoft(softTtlMs);
4✔
803
    extendExpiry(cacheKey, hard, soft, true, true);
7✔
804
  }
1✔
805

806
  /**
807
   * Extend only the hard expiry for a cache entry, leaving the soft expiry
808
   * unchanged. Useful when promoting a NORMAL or COOL entry to HOT — the
809
   * hard TTL must be lengthened to the hot‑key value, but the existing soft
810
   * expiry (if any) should be preserved because it reflects a more recent
811
   * refresh cycle.
812
   *
813
   * <p>If the caller passes {@code 0} the configured default hot hard TTL
814
   * ({@link #getEffectiveHotHardTtlMs()}) is used.
815
   *
816
   * @param cacheKey  the key whose hard expiry should be extended
817
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
818
   *                  configured hot hard TTL
819
   */
820
  public void extendHardExpiry(String cacheKey, long hardTtlMs) {
821
    long hard = resolveEffectiveHotHard(hardTtlMs);
4✔
822
    extendExpiry(cacheKey, hard, 0, true, false);
7✔
823
  }
1✔
824

825
  /**
826
   * Extend only the soft expiry for a cache entry, leaving the hard expiry
827
   * unchanged. Useful when a background refresh has completed and the caller
828
   * wants to reset the soft TTL without affecting the hard TTL.
829
   *
830
   * <p>If the caller passes {@code 0} the configured default hot soft TTL
831
   * ({@link #getEffectiveHotSoftTtlMs()}) is used.
832
   *
833
   * @param cacheKey  the key whose soft expiry should be extended
834
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
835
   *                  configured hot soft TTL
836
   */
837
  public void extendSoftExpiry(String cacheKey, long softTtlMs) {
838
    long soft = resolveEffectiveHotSoft(softTtlMs);
4✔
839
    extendExpiry(cacheKey, 0, soft, false, true);
7✔
840
  }
1✔
841

842
  /**
843
   * Atomically update the expiry timestamps of an existing cache entry.
844
   *
845
   * @param cacheKey    the key whose expiry should be extended
846
   * @param hardTtlMs   new hard TTL in milliseconds (ignored if {@code updateHard} is false)
847
   * @param softTtlMs   new soft TTL in milliseconds (ignored if {@code updateSoft} is false)
848
   * @param updateHard  whether to update the hard expiry timestamp
849
   * @param updateSoft  whether to update the soft expiry timestamp
850
   */
851
  private void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs, boolean updateHard, boolean updateSoft) {
852
    caffeineCache
2✔
853
      .asMap()
8✔
854
      .computeIfPresent(cacheKey, (k, existing) -> {
2✔
855
        if (existing instanceof CacheEntry entry) {
6!
856
          if (updateHard) {
2✔
857
            entry = applyHardTtl(entry, resolveEffectiveHardTtl(hardTtlMs));
7✔
858
          }
859
          if (updateSoft) {
2✔
860
            entry = applySoftTtl(entry, resolveEffectiveSoftTtl(softTtlMs));
7✔
861
          }
862
          return entry;
2✔
863
        }
864
        return existing;
×
865
      });
866
  }
1✔
867
}
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