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

Hyshmily / hotkey / 28845108212

07 Jul 2026 06:00AM UTC coverage: 90.477% (+0.9%) from 89.623%
28845108212

push

github

Hyshmily
fix and perf: simplified the code and promote the Weigher accuracy,performance1

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

1312 of 1516 branches covered (86.54%)

Branch coverage included in aggregate %.

102 of 121 new or added lines in 5 files covered. (84.3%)

2 existing lines in 2 files now uncovered.

3714 of 4039 relevant lines covered (91.95%)

4.24 hits per line

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

92.94
/common/src/main/java/io/github/hyshmily/hotkey/cache/cachesupport/impl/ExpireManagerImpl.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.impl;
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.cache.cachesupport.ExpireManager;
24
import io.github.hyshmily.hotkey.cache.codec.CacheCompressor;
25
import io.github.hyshmily.hotkey.model.CacheEntry;
26
import io.github.hyshmily.hotkey.model.KeyState;
27
import io.github.hyshmily.hotkey.util.DelayUtil;
28
import io.github.hyshmily.hotkey.util.TimeSource;
29
import java.util.Optional;
30
import java.util.concurrent.*;
31
import java.util.function.Supplier;
32
import lombok.Getter;
33
import lombok.extern.slf4j.Slf4j;
34

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

46
  /** The underlying L1 Caffeine cache instance. */
47
  private final Cache<String, Object> caffeineCache;
48
  /** Async executor for background refresh tasks. */
49
  private final Executor executor;
50
  /** TTL configuration providing normal and hot-key TTL values. */
51
  private final HotKeyProperties ttlConfig;
52
  /** Whether soft expire (stale-while-revalidate) is enabled (cached from config at construction).*/
53
  private final boolean softExpireEnabled;
54
  /** Semaphore limiting concurrent background refresh operations (null if soft expire disabled). */
55
  // null when soft expire is disabled; always guarded by softExpireEnabled check
56
  private final Semaphore refreshLimiter;
57
  /** Per-key dedup for background refreshes — prevents concurrent refresh for the same key. */
58
  private final ConcurrentHashMap<String, CompletableFuture<?>> pendingRefreshes = new ConcurrentHashMap<>();
5✔
59
  /** Compressor for L1 cache values. */
60
  private final CacheCompressor compressor;
61

62
  /** Jitter ratio applied to TTLs to prevent cache stampedes (from config, default 0.05 = ±5%). */
63
  private final double defaultTtlJitterRatio;
64

65
  private static final long refreshTimeoutSeconds = 30;
66

67
  private record EntrySnapshot(
18✔
68
    long dataVersion,
69
    long decisionVersion,
70
    String decisionNodeId,
71
    long decisionEpoch,
72
    KeyState keyState
73
  ) {
74
    static final EntrySnapshot DEFAULT = new EntrySnapshot(VERSION_DEFAULT, VERSION_DEFAULT, null, 0L, KeyState.NORMAL);
10✔
75
  }
76

77
  private static EntrySnapshot snapshotEntry(Object raw) {
78
    if (raw instanceof CacheEntry entry) {
6!
79
      return new EntrySnapshot(
4✔
80
        entry.getDataVersion(),
2✔
81
        entry.getDecisionVersion(),
2✔
82
        entry.getDecisionNodeId(),
2✔
83
        entry.getDecisionEpoch(),
2✔
84
        entry.getKeyState()
2✔
85
      );
86
    }
87
    return EntrySnapshot.DEFAULT;
×
88
  }
89

90
  /**
91
   * Creates a ExpireManagerImpl with the given Caffeine cache, executor, and TTL config.
92
   *
93
   * @param caffeineCache   the underlying L1 Caffeine cache
94
   * @param executor        async executor for background refresh
95
   * @param ttlConfig       TTL configuration (normal and hot-key variants)
96
   * @param refreshMaxPools maximum concurrent background refreshes (capped at 100)
97
   */
98
  public ExpireManagerImpl(
99
    Cache<String, Object> caffeineCache,
100
    Executor executor,
101
    HotKeyProperties ttlConfig,
102
    int refreshMaxPools
103
  ) {
104
    this(caffeineCache, executor, ttlConfig, refreshMaxPools, CacheCompressor.NONE);
7✔
105
  }
1✔
106

107
  /**
108
   * Creates a ExpireManagerImpl with the given Caffeine cache, executor, TTL config,
109
   * and a {@link CacheCompressor} for L1 value compression.
110
   *
111
   * @param caffeineCache   the underlying L1 Caffeine cache
112
   * @param executor        async executor for background refresh
113
   * @param ttlConfig       TTL configuration (normal and hot-key variants)
114
   * @param refreshMaxPools maximum concurrent background refreshes (capped at 100)
115
   * @param compressor      compressor for L1 cache values
116
   */
117
  public ExpireManagerImpl(
118
    Cache<String, Object> caffeineCache,
119
    Executor executor,
120
    HotKeyProperties ttlConfig,
121
    int refreshMaxPools,
122
    CacheCompressor compressor
123
  ) {
2✔
124
    this.caffeineCache = caffeineCache;
3✔
125
    this.executor = executor;
3✔
126
    this.ttlConfig = ttlConfig;
3✔
127
    this.compressor = compressor;
3✔
128
    this.softExpireEnabled = ttlConfig.isSoftExpireEnabled();
4✔
129
    this.refreshLimiter = initRefreshLimiter(refreshMaxPools);
5✔
130
    this.defaultTtlJitterRatio = ttlConfig.getTtlJitterRatio();
4✔
131
  }
1✔
132

133
  /**
134
   * Create a ExpireManagerImpl with explicit jitter ratio (for testing).
135
   */
136
  ExpireManagerImpl(
137
    Cache<String, Object> caffeineCache,
138
    Executor executor,
139
    HotKeyProperties ttlConfig,
140
    int refreshMaxPools,
141
    double defaultTtlJitterRatio,
142
    CacheCompressor compressor
143
  ) {
×
144
    this.caffeineCache = caffeineCache;
×
145
    this.executor = executor;
×
146
    this.ttlConfig = ttlConfig;
×
147
    this.compressor = compressor;
×
NEW
148
    this.softExpireEnabled = ttlConfig.isSoftExpireEnabled();
×
NEW
149
    this.refreshLimiter = initRefreshLimiter(refreshMaxPools);
×
150
    this.defaultTtlJitterRatio = defaultTtlJitterRatio;
×
151
  }
×
152

153
  private Semaphore initRefreshLimiter(int refreshMaxPools) {
154
    int effectiveRefreshMaxPools = refreshMaxPools > 0 ? refreshMaxPools : 100;
6✔
155
    return softExpireEnabled ? new Semaphore(effectiveRefreshMaxPools) : null;
10✔
156
  }
157

158
  /**
159
   * Check whether a {@link CacheEntry} has logically expired based on its
160
   * {@code hardExpireAtMs}.  Entries with {@code hardExpireAtMs == Long.MAX_VALUE}
161
   * are treated as permanent (never logically expire).
162
   *
163
   * @param entry the cache entry to inspect
164
   * @return {@code true} if the entry has logically expired
165
   */
166
  public boolean isLogicallyExpired(CacheEntry entry) {
167
    return entry.getHardExpireAtMs() != Long.MAX_VALUE && TimeSource.currentTimeMillis() >= entry.getHardExpireAtMs();
14✔
168
  }
169

170
  /**
171
   * Check whether the given raw cache value is a logically expired {@link CacheEntry}
172
   * and, if so, invalidate it and return {@code true}.
173
   * <p>Eliminates code duplication between {@link io.github.hyshmily.hotkey.cache.HotKeyCache#get}
174
   * and {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire},
175
   * which both perform this check before and after side effects (TOCTOU guard).
176
   *
177
   * @param cacheKey the cache key to invalidate if expired
178
   * @param raw      the raw value from the Caffeine cache
179
   * @return {@code true} if the entry was expired and has been invalidated
180
   */
181
  public boolean invalidateIfIsLogicallyExpired(String cacheKey, Object raw) {
182
    if (raw instanceof CacheEntry ce && isLogicallyExpired(ce)) {
10✔
183
      caffeineCache.invalidate(cacheKey);
4✔
184
      log.debug("Cache entry logically expired during processing, reloading: {}", cacheKey);
4✔
185
      return true;
2✔
186
    }
187
    return false;
2✔
188
  }
189

190
  public long computeNullExpireAt(long nullTtlMs) {
191
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
10✔
192
    return toHardExpireTimestamp(effective);
4✔
193
  }
194

195
  /**
196
   * Hard expire timestamp from an explicit TTL duration.
197
   * Falls back to the normal-key default if {@code hardTtlMs <= 0}.
198
   *
199
   * @param hardTtlMs the hard TTL duration in milliseconds (&lt;= 0 uses configured default)
200
   * @return absolute epoch-ms timestamp for hard expiry
201
   */
202
  public long computeHardExpireAt(long hardTtlMs) {
203
    return toHardExpireTimestamp(resolveEffectiveHardTtl(hardTtlMs));
6✔
204
  }
205

206
  /**
207
   * Hard expire timestamp for hot keys, using {@code default-hot-hard-ttl} / {@code hot-hard-ttl}.
208
   * Returns {@code Long.MAX_VALUE} if hot hard expire is disabled (TTL &lt;= 0).
209
   *
210
   * @return absolute epoch-ms timestamp for hot-key hard expiry
211
   */
212
  public long computeHotHardExpireAt() {
213
    return toHardExpireTimestamp(ttlConfig.effectiveHotHardTtlMs());
6✔
214
  }
215

216
  /**
217
   * Soft expire timestamp for hot keys, using {@code default-hot-soft-ttl} / {@code hot-soft-ttl}.
218
   *
219
   * @return absolute epoch-ms timestamp for hot-key soft expiry, or 0 if disabled
220
   */
221
  public long computeHotSoftExpireAt() {
222
    return toSoftExpireTimestamp(ttlConfig.effectiveHotSoftTtlMs());
6✔
223
  }
224

225
  /**
226
   * Soft expire timestamp from an explicit TTL duration.
227
   * Falls back to the normal-key default if {@code softTtlMs <= 0}. Returns 0 if soft expire is disabled.
228
   *
229
   * @param softTtlMs the soft TTL duration in milliseconds (&lt;= 0 uses configured default)
230
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
231
   */
232
  public long computeSoftExpireAt(long softTtlMs) {
233
    if (!isSoftExpireEnabled()) {
3✔
234
      return 0L;
2✔
235
    }
236
    return toSoftExpireTimestamp(resolveEffectiveSoftTtl(softTtlMs));
6✔
237
  }
238

239
  /**
240
   * Effective hard TTL for normal keys (override > default).
241
   *
242
   * @return effective hard TTL duration in milliseconds
243
   */
244
  public long getEffectiveHardTtlMs() {
245
    return ttlConfig.effectiveHardTtlMs();
4✔
246
  }
247

248
  /**
249
   * Resolve effective hard TTL for normal keys: use the override value if
250
   * positive, otherwise fall back to the configured default.
251
   *
252
   * @param hardTtlMs hard TTL override ({@code 0} or negative uses default)
253
   * @return effective hard TTL duration in milliseconds
254
   */
255
  public long resolveEffectiveHardTtl(long hardTtlMs) {
256
    return hardTtlMs > 0 ? hardTtlMs : getEffectiveHardTtlMs();
9✔
257
  }
258

259
  /**
260
   * Effective hard TTL for hot keys (override > default).
261
   *
262
   * @return effective hot hard TTL duration in milliseconds
263
   */
264
  public long getEffectiveHotHardTtlMs() {
265
    return ttlConfig.effectiveHotHardTtlMs();
4✔
266
  }
267

268
  /**
269
   * Resolve effective hard TTL for hot keys: use the override value if
270
   * positive, otherwise fall back to the configured hot-key hard TTL.
271
   *
272
   * @param hardTtlMs hard TTL override ({@code 0} or negative uses default)
273
   * @return effective hot-key hard TTL duration in milliseconds
274
   */
275
  public long resolveEffectiveHotHard(long hardTtlMs) {
276
    return hardTtlMs > 0 ? hardTtlMs : getEffectiveHotHardTtlMs();
9✔
277
  }
278

279
  /**
280
   * Effective soft TTL for normal keys (override > default).
281
   *
282
   * @return effective soft TTL duration in milliseconds
283
   */
284
  public long getEffectiveSoftTtlMs() {
285
    return ttlConfig.effectiveSoftTtlMs();
4✔
286
  }
287

288
  /**
289
   * Resolve effective soft TTL for normal keys: use the override value if
290
   * positive, otherwise fall back to the configured default.
291
   *
292
   * @param softTtlMs soft TTL override ({@code 0} or negative uses default)
293
   * @return effective soft TTL duration in milliseconds
294
   */
295
  public long resolveEffectiveSoftTtl(long softTtlMs) {
296
    return softTtlMs > 0 ? softTtlMs : getEffectiveSoftTtlMs();
9✔
297
  }
298

299
  /**
300
   * Effective soft TTL for hot keys (override > default).
301
   *
302
   * @return effective hot soft TTL duration in milliseconds
303
   */
304
  public long getEffectiveHotSoftTtlMs() {
305
    return ttlConfig.effectiveHotSoftTtlMs();
4✔
306
  }
307

308
  /**
309
   * Resolve effective soft TTL for hot keys: use the override value if
310
   * positive, otherwise fall back to the configured hot-key soft TTL.
311
   *
312
   * @param softTtlMs soft TTL override ({@code 0} or negative uses default)
313
   * @return effective hot-key soft TTL duration in milliseconds
314
   */
315
  public long resolveEffectiveHotSoft(long softTtlMs) {
316
    return softTtlMs > 0 ? softTtlMs : getEffectiveHotSoftTtlMs();
9✔
317
  }
318

319
  /**
320
   * Build a {@link CacheEntry} from fully resolved fields, including
321
   * decision metadata (node, epoch), pre-computed expire timestamps,
322
   * and normal-TTL values. Normal TTL is applied via
323
   * {@link #applyNormalTtl} after construction.
324
   * <p>
325
   * This overload accepts pre-computed hard/soft expire timestamps,
326
   * which is useful when the caller already knows the exact expiry
327
   * baseline (e.g., when copying from an existing entry).
328
   *
329
   * @param value              the cached value
330
   * @param dataVersion        the data version for cross-instance sync
331
   * @param isVersionDegraded  whether the data version is degraded (local fallback)
332
   * @param decisionVersion    the Worker decision version
333
   * @param decisionNodeId     the Worker node ID that produced the decision
334
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
335
   * @param hardTtlMs          hard TTL duration in milliseconds
336
   * @param softTtlMs          soft TTL duration in milliseconds
337
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
338
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
339
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
340
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
341
   * @param keyState           the initial key state (NORMAL, HOT, COOL)
342
   * @return a new {@link CacheEntry} with all fields set
343
   */
344
  public CacheEntry createBuilder(
345
    Object value,
346
    long dataVersion,
347
    boolean isVersionDegraded,
348
    long decisionVersion,
349
    String decisionNodeId,
350
    long decisionEpoch,
351
    long hardTtlMs,
352
    long softTtlMs,
353
    long hardExpireAtMs,
354
    long softExpireAtMs,
355
    long normalHardTtlMs,
356
    long normalSoftTtlMs,
357
    KeyState keyState
358
  ) {
359
    return applyNormalTtl(
3✔
360
      CacheEntry.builder()
4✔
361
        .value(compressor.wrap(value))
3✔
362
        .dataVersion(dataVersion)
2✔
363
        .isVersionDegraded(isVersionDegraded)
2✔
364
        .decisionVersion(decisionVersion)
2✔
365
        .decisionNodeId(decisionNodeId)
2✔
366
        .decisionEpoch(decisionEpoch)
2✔
367
        .hardTtlMs(hardTtlMs)
2✔
368
        .softTtlMs(softTtlMs)
2✔
369
        .hardExpireAtMs(hardExpireAtMs)
2✔
370
        .softExpireAtMs(softExpireAtMs)
2✔
371
        .keyState(keyState)
1✔
372
        .build(),
3✔
373
      normalHardTtlMs,
374
      normalSoftTtlMs
375
    );
376
  }
377

378
  /**
379
   * Build a {@link CacheEntry} from fully resolved fields with decision
380
   * metadata but without pre-computed expire timestamps. Expire timestamps
381
   * are computed automatically via {@link #applyTtl}.
382
   * <p>
383
   * The {@code hardTtlMs} and {@code softTtlMs} passed here are used both
384
   * as field values <em>and</em> as inputs to {@code applyTtl}, which
385
   * overwrites the expire-at timestamps. This is the typical path for
386
   * entries sourced from a remote reader (Worker or Redis) where the
387
   * caller does not pre-compute timestamps.
388
   *
389
   * @param value              the cached value
390
   * @param dataVersion        the data version for cross-instance sync
391
   * @param isVersionDegraded  whether the data version is degraded
392
   * @param decisionVersion    the Worker decision version
393
   * @param decisionNodeId     the Worker node ID that produced the decision
394
   * @param decisionEpoch      the epoch (restart counter) of the decision Worker
395
   * @param hardTtlMs          hard TTL duration in milliseconds
396
   * @param softTtlMs          soft TTL duration in milliseconds
397
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
398
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
399
   * @param keyState           the initial key state
400
   * @return a new {@link CacheEntry} with expire timestamps computed
401
   */
402
  public CacheEntry createBuilder(
403
    Object value,
404
    long dataVersion,
405
    boolean isVersionDegraded,
406
    long decisionVersion,
407
    String decisionNodeId,
408
    long decisionEpoch,
409
    long hardTtlMs,
410
    long softTtlMs,
411
    long normalHardTtlMs,
412
    long normalSoftTtlMs,
413
    KeyState keyState
414
  ) {
415
    return applyTtl(
4✔
416
      applyNormalTtl(
3✔
417
        CacheEntry.builder()
4✔
418
          .value(compressor.wrap(value))
3✔
419
          .dataVersion(dataVersion)
2✔
420
          .isVersionDegraded(isVersionDegraded)
2✔
421
          .decisionVersion(decisionVersion)
2✔
422
          .decisionNodeId(decisionNodeId)
2✔
423
          .decisionEpoch(decisionEpoch)
2✔
424
          .hardTtlMs(hardTtlMs)
2✔
425
          .softTtlMs(softTtlMs)
2✔
426
          .keyState(keyState)
1✔
427
          .build(),
3✔
428
        normalHardTtlMs,
429
        normalSoftTtlMs
430
      ),
431
      hardTtlMs,
432
      softTtlMs
433
    );
434
  }
435

436
  /**
437
   * Build a {@link CacheEntry} with pre-computed expire timestamps
438
   * but without decision node/epoch metadata. Normal TTL is applied
439
   * via {@link #applyNormalTtl} after construction.
440
   * <p>
441
   * This overload omits {@code decisionNodeId} and {@code decisionEpoch},
442
   * which is appropriate for entries created by local promotion (no
443
   * Worker origin). The expire timestamps are caller-supplied.
444
   *
445
   * @param value              the cached value
446
   * @param dataVersion        the data version for cross-instance sync
447
   * @param isVersionDegraded  whether the data version is degraded
448
   * @param decisionVersion    the Worker decision version (0 for local)
449
   * @param hardTtlMs          hard TTL duration in milliseconds
450
   * @param softTtlMs          soft TTL duration in milliseconds
451
   * @param hardExpireAtMs     pre-computed hard expiry absolute timestamp
452
   * @param softExpireAtMs     pre-computed soft expiry absolute timestamp
453
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
454
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
455
   * @param keyState           the initial key state
456
   * @return a new {@link CacheEntry} with all fields set
457
   */
458
  public CacheEntry createBuilder(
459
    Object value,
460
    long dataVersion,
461
    boolean isVersionDegraded,
462
    long decisionVersion,
463
    long hardTtlMs,
464
    long softTtlMs,
465
    long hardExpireAtMs,
466
    long softExpireAtMs,
467
    long normalHardTtlMs,
468
    long normalSoftTtlMs,
469
    KeyState keyState
470
  ) {
471
    return applyNormalTtl(
3✔
472
      CacheEntry.builder()
4✔
473
        .value(compressor.wrap(value))
3✔
474
        .dataVersion(dataVersion)
2✔
475
        .isVersionDegraded(isVersionDegraded)
2✔
476
        .decisionVersion(decisionVersion)
2✔
477
        .hardTtlMs(hardTtlMs)
2✔
478
        .softTtlMs(softTtlMs)
2✔
479
        .hardExpireAtMs(hardExpireAtMs)
2✔
480
        .softExpireAtMs(softExpireAtMs)
2✔
481
        .keyState(keyState)
1✔
482
        .build(),
3✔
483
      normalHardTtlMs,
484
      normalSoftTtlMs
485
    );
486
  }
487

488
  /**
489
   * Build a {@link CacheEntry} from raw fields without pre-computed
490
   * expire timestamps or decision metadata. Expire timestamps are
491
   * computed via {@link #applyTtl} after normal TTL is set.
492
   * <p>
493
   * This is the most compact overload, suitable for local promotions
494
   * where the caller has no Worker decision context and wants
495
   * timestamps computed automatically.
496
   *
497
   * @param value              the cached value
498
   * @param dataVersion        the data version for cross-instance sync
499
   * @param isVersionDegraded  whether the data version is degraded
500
   * @param decisionVersion    the Worker decision version (0 for local)
501
   * @param hardTtlMs          hard TTL duration in milliseconds
502
   * @param softTtlMs          soft TTL duration in milliseconds
503
   * @param normalHardTtlMs    normal (non-hot) hard TTL for state reversion
504
   * @param normalSoftTtlMs    normal (non-hot) soft TTL for state reversion
505
   * @param keyState           the initial key state
506
   * @return a new {@link CacheEntry} with expire timestamps computed
507
   */
508
  public CacheEntry createBuilder(
509
    Object value,
510
    long dataVersion,
511
    boolean isVersionDegraded,
512
    long decisionVersion,
513
    long hardTtlMs,
514
    long softTtlMs,
515
    long normalHardTtlMs,
516
    long normalSoftTtlMs,
517
    KeyState keyState
518
  ) {
519
    return applyTtl(
4✔
520
      applyNormalTtl(
3✔
521
        CacheEntry.builder()
4✔
522
          .value(compressor.wrap(value))
3✔
523
          .dataVersion(dataVersion)
2✔
524
          .isVersionDegraded(isVersionDegraded)
2✔
525
          .decisionVersion(decisionVersion)
2✔
526
          .keyState(keyState)
1✔
527
          .build(),
3✔
528
        normalHardTtlMs,
529
        normalSoftTtlMs
530
      ),
531
      hardTtlMs,
532
      softTtlMs
533
    );
534
  }
535

536
  @Override
537
  public CacheEntry replaceEntryValue(CacheEntry entry, Object newValue) {
538
    return entry.toBuilder().value(compressor.wrap(newValue)).build();
9✔
539
  }
540

541
  /**
542
   * Create a copy of the entry with the normal (non-hot) TTL values set,
543
   * leaving all other fields (hot TTLs, versions, state) untouched.
544
   * <p>
545
   * The normal TTLs ({@code normalHardTtlMs}, {@code normalSoftTtlMs})
546
   * are the baseline TTL values that the entry reverts to when its key
547
   * state transitions from HOT back to NORMAL. These are recorded at
548
   * entry creation and preserved across state transitions.
549
   *
550
   * @param original   the source {@link CacheEntry} to copy
551
   * @param hardTtlMs  normal hard TTL duration in milliseconds
552
   * @param softTtlMs  normal soft TTL duration in milliseconds
553
   * @return a new {@link CacheEntry} with the normal TTL fields updated
554
   */
555
  public CacheEntry applyNormalTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
556
    return original.toBuilder().normalHardTtlMs(hardTtlMs).normalSoftTtlMs(softTtlMs).build();
8✔
557
  }
558

559
  /**
560
   * Create a new {@link CacheEntry} with updated TTL fields, preserving all
561
   * other metadata from the supplied original entry.
562
   *
563
   * <p>Sets {@code hardTtlMs}, {@code softTtlMs},
564
   * {@code hardExpireAtMs} (via {@link #computeHardExpireAt}),
565
   * and {@code softExpireAtMs} (via {@link #computeSoftExpireAt}).
566
   *
567
   * @param original   an existing {@link CacheEntry} whose metadata should be preserved;
568
   *                   must not be null
569
   * @param hardTtlMs  hard TTL duration in milliseconds
570
   * @param softTtlMs  soft TTL duration in milliseconds
571
   * @return a new {@link CacheEntry} with the updated TTL timestamps,
572
   *         while keeping all version, state, and normal TTL fields unchanged
573
   */
574
  public CacheEntry applyTtl(CacheEntry original, long hardTtlMs, long softTtlMs) {
575
    return original
2✔
576
      .toBuilder()
2✔
577
      .hardTtlMs(hardTtlMs)
2✔
578
      .softTtlMs(softTtlMs)
3✔
579
      .hardExpireAtMs(computeHardExpireAt(hardTtlMs))
4✔
580
      .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
581
      .build();
1✔
582
  }
583

584
  /**
585
   * Create a copy of the entry with only the hard TTL updated, leaving the
586
   * existing soft TTL and all version/state fields untouched.
587
   *
588
   * @param original   the source {@link CacheEntry} to copy
589
   * @param hardTtlMs  hard TTL duration in milliseconds
590
   * @return a new {@link CacheEntry} with the updated hard TTL and expiration
591
   */
592
  public CacheEntry applyHardTtl(CacheEntry original, long hardTtlMs) {
593
    return original.toBuilder().hardTtlMs(hardTtlMs).hardExpireAtMs(computeHardExpireAt(hardTtlMs)).build();
10✔
594
  }
595

596
  /**
597
   * Create a copy of the entry with only the soft TTL updated, leaving the
598
   * existing hard TTL and all version/state fields untouched.
599
   *
600
   * @param original   the source {@link CacheEntry} to copy
601
   * @param softTtlMs  soft TTL duration in milliseconds
602
   * @return a new {@link CacheEntry} with the updated soft TTL and expiration
603
   */
604
  public CacheEntry applySoftTtl(CacheEntry original, long softTtlMs) {
605
    return original.toBuilder().softTtlMs(softTtlMs).softExpireAtMs(computeSoftExpireAt(softTtlMs)).build();
10✔
606
  }
607

608
  /**
609
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
610
   * using the configured default jitter ratio.
611
   * Propagates {@link Long#MAX_VALUE} unchanged — used to signal permanent entries
612
   * (pure logical expiry with no hard TTL eviction).
613
   */
614
  public long toHardExpireTimestamp(long hardTtlMs) {
615
    return toHardExpireTimestamp(hardTtlMs, defaultTtlJitterRatio);
6✔
616
  }
617

618
  /**
619
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
620
   * using the given jitter ratio instead of the configured default.
621
   * Propagates {@link Long#MAX_VALUE} unchanged.
622
   *
623
   * @param hardTtlMs      the hard TTL duration in milliseconds
624
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
625
   * @return absolute epoch-ms timestamp for hard expiry
626
   */
627
  public long toHardExpireTimestamp(long hardTtlMs, double ttlJitterRatio) {
628
    if (hardTtlMs == Long.MAX_VALUE) {
4✔
629
      return Long.MAX_VALUE;
2✔
630
    }
631
    long jitter = DelayUtil.computeTtlJitter(hardTtlMs, ttlJitterRatio);
4✔
632

633
    return hardTtlMs > 0 ? TimeSource.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
634
  }
635

636
  /**
637
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp.
638
   * Applies configurable jitter (default ±5%) to prevent cache stampedes.
639
   * Returns 0 if soft expire is disabled or the TTL is non-positive.
640
   *
641
   * @param softTtlMs the soft TTL duration in milliseconds
642
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
643
   */
644
  public long toSoftExpireTimestamp(long softTtlMs) {
645
    return toSoftExpireTimestamp(softTtlMs, defaultTtlJitterRatio);
6✔
646
  }
647

648
  /**
649
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp
650
   * using the given jitter ratio instead of the configured default.
651
   * Returns 0 if soft expire is disabled. Propagates {@link Long#MAX_VALUE} unchanged.
652
   *
653
   * @param softTtlMs      the soft TTL duration in milliseconds
654
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
655
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
656
   */
657
  public long toSoftExpireTimestamp(long softTtlMs, double ttlJitterRatio) {
658
    if (!isSoftExpireEnabled() || softTtlMs <= 0) {
7✔
659
      return 0L;
2✔
660
    }
661
    if (softTtlMs == Long.MAX_VALUE) {
4✔
662
      return Long.MAX_VALUE;
2✔
663
    }
664
    long jitter = DelayUtil.computeTtlJitter(softTtlMs, ttlJitterRatio);
4✔
665

666
    return TimeSource.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
667
  }
668

669
  /**
670
   * Check whether the given key's soft TTL has expired.
671
   *
672
   * @return {@code true} if the entry's soft TTL has expired or the entry is absent
673
   * @throws IllegalStateException if soft expire is disabled
674
   */
675
  public boolean isSoftExpired(Object cacheEntry) {
676
    if (!isSoftExpireEnabled()) {
3✔
677
      throw new IllegalStateException("ExpireManager soft expire is disabled, isSoftExpired() should not be called");
5✔
678
    }
679
    if (cacheEntry instanceof CacheEntry ce) {
6✔
680
      long expireAt = ce.getSoftExpireAtMs();
3✔
681
      return expireAt <= 0 || expireAt < TimeSource.currentTimeMillis();
12✔
682
    }
683
    return true;
2✔
684
  }
685

686
  /**
687
   * Triggers an asynchronous background refresh for the given cache key if the
688
   * current entry has reached its soft expiry threshold. The caller (typically
689
   * {@link io.github.hyshmily.hotkey.cache.HotKeyCache#getWithSoftExpire HotKeyCache.getWithSoftExpire}) has already returned the stale value to the
690
   * client, so this method executes entirely in the background without blocking
691
   * the caller.
692
   *
693
   * <p><b>Concurrency:</b> uses {@link ConcurrentHashMap#compute} on
694
   * {@code pendingRefreshes} to atomically decide whether a new refresh task
695
   * should be launched. This eliminates the earlier DCL (double-checked locking)
696
   * pattern that required manual clean-up of a placeholder
697
   * {@link CompletableFuture} when the limiter rejected the task or the executor
698
   * was saturated.
699
   *
700
   * @param cacheKey  the key whose value should be refreshed
701
   * @param reader    the data-source supplier
702
   * @param softTtlMs the soft TTL to set on the refreshed entry (milliseconds)
703
   */
704
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
705
    if (!isSoftExpireEnabled()) {
3✔
706
      return;
1✔
707
    }
708
    pendingRefreshes.compute(cacheKey, (k, existing) -> {
10✔
709
      // If there is already an in-flight refresh for this key, keep the
710
      // existing future and do nothing.
711
      if (existing != null && !existing.isDone()) {
5!
712
        return existing;
2✔
713
      }
714

715
      // Try to acquire a permit from the global refresh limiter.
716
      if (!refreshLimiter.tryAcquire()) {
4✔
717
        log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
718
        // Returning null removes any previous entry, leaving no stale marker.
719
        return null;
2✔
720
      }
721

722
      try {
723
        // Snapshot the current entry metadata so we can detect superseding
724
        // writes and preserve Worker decision state across the refresh.
725
        EntrySnapshot snap = snapshotEntry(caffeineCache.getIfPresent(cacheKey));
6✔
726
        final long refreshStartDataVersion = snap.dataVersion();
3✔
727
        final long refreshStartDecisionVersion = snap.decisionVersion();
3✔
728
        final String refreshStartDecisionNodeId = snap.decisionNodeId();
3✔
729
        final long refreshStartDecisionEpoch = snap.decisionEpoch();
3✔
730
        final KeyState refreshStartKeyState = snap.keyState();
3✔
731

732
        // Build the async refresh task with timeout protection.
733
        CompletableFuture<?> task;
734
        try {
735
          task = CompletableFuture.supplyAsync(reader, executor).orTimeout(refreshTimeoutSeconds, TimeUnit.SECONDS);
8✔
736
        } catch (RejectedExecutionException e) {
1✔
737
          // Executor saturated – release the limiter permit and leave no
738
          // pending marker so the next read can retry immediately.
739
          refreshLimiter.release();
3✔
740
          log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
741
          return null;
2✔
742
        }
1✔
743

744
        // When the refresh completes (success, failure or timeout), update the
745
        // cache entry if the value is still applicable, and always release the
746
        // resources.
747
        task.whenComplete((value, error) -> {
12✔
748
          try {
749
            if (error != null) {
2✔
750
              if (error instanceof TimeoutException) {
3!
751
                log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
752
              } else {
753
                log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
754
              }
755
              return;
1✔
756
            }
757
            if (value != null) {
2✔
758
              caffeineCache
2✔
759
                .asMap()
12✔
760
                .compute(cacheKey, (key, existingEntry) ->
2✔
761
                  Optional.ofNullable(existingEntry)
5✔
762
                    .filter(CacheEntry.class::isInstance)
6✔
763
                    .map(CacheEntry.class::cast)
10✔
764
                    .map(entry -> {
9✔
765
                      // Version guard: if a newer write has
766
                      // arrived while we were refreshing,
767
                      // discard the stale refresh result.
768
                      if (entry.getDataVersion() > refreshStartDataVersion) {
5✔
769
                        log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
770
                        return entry;
2✔
771
                      }
772
                      return applySoftTtl(replaceEntryValue(entry, value), softTtlMs);
8✔
773
                    })
774
                    .orElseGet(() ->
1✔
775
                      createBuilder(
14✔
776
                        value,
777
                        VERSION_DEFAULT,
778
                        false,
779
                        refreshStartDecisionVersion,
780
                        refreshStartDecisionNodeId,
781
                        refreshStartDecisionEpoch,
782
                        0L,
783
                        softTtlMs,
784
                        Long.MAX_VALUE,
785
                        computeSoftExpireAt(softTtlMs),
4✔
786
                        0L,
787
                        0L,
788
                        refreshStartKeyState
789
                      )
790
                    )
791
                );
792
            }
793
          } finally {
794
            // Always release the limiter permit and remove the in-flight
795
            // marker so that a future refresh can be scheduled.
796
            refreshLimiter.release();
3✔
797
            pendingRefreshes.remove(cacheKey);
5✔
798
          }
799
        });
1✔
800

801
        // Return the new task; it will be stored in the map and visible to
802
        // any concurrent caller for this key.
803
        return task;
2✔
804
      } catch (Throwable t) {
×
805
        // Unexpected failure before task creation (getIfPresent NPE, supplyAsync Error,
806
        // etc.) — release the semaphore so the refresh limiter does not permanently
807
        // lose a slot. The compute() will not store any entry, so the next read can retry.
808
        refreshLimiter.release();
×
809
        throw t;
×
810
      }
811
    });
812
  }
1✔
813

814
  /**
815
   * Extend both the hard and soft expiry for a cache entry.
816
   *
817
   * <p>If the caller passes {@code 0} for either TTL, the configured default
818
   * hot TTL ({@link #getEffectiveHotHardTtlMs()} / {@link #getEffectiveHotSoftTtlMs()})
819
   * is used.
820
   *
821
   * @param cacheKey  the key whose expiry should be extended
822
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
823
   *                  configured hot hard TTL
824
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
825
   *                  configured hot soft TTL
826
   */
827
  public void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs) {
828
    long hard = resolveEffectiveHotHard(hardTtlMs);
4✔
829
    long soft = resolveEffectiveHotSoft(softTtlMs);
4✔
830
    extendExpiry(cacheKey, hard, soft, true, true);
7✔
831
  }
1✔
832

833
  /**
834
   * Extend only the hard expiry for a cache entry, leaving the soft expiry
835
   * unchanged. Useful when promoting a NORMAL or COOL entry to HOT — the
836
   * hard TTL must be lengthened to the hot‑key value, but the existing soft
837
   * expiry (if any) should be preserved because it reflects a more recent
838
   * refresh cycle.
839
   *
840
   * <p>If the caller passes {@code 0} the configured default hot hard TTL
841
   * ({@link #getEffectiveHotHardTtlMs()}) is used.
842
   *
843
   * @param cacheKey  the key whose hard expiry should be extended
844
   * @param hardTtlMs new hard TTL in milliseconds; {@code 0} to use the
845
   *                  configured hot hard TTL
846
   */
847
  public void extendHardExpiry(String cacheKey, long hardTtlMs) {
848
    long hard = resolveEffectiveHotHard(hardTtlMs);
4✔
849
    extendExpiry(cacheKey, hard, 0, true, false);
7✔
850
  }
1✔
851

852
  /**
853
   * Extend only the soft expiry for a cache entry, leaving the hard expiry
854
   * unchanged. Useful when a background refresh has completed and the caller
855
   * wants to reset the soft TTL without affecting the hard TTL.
856
   *
857
   * <p>If the caller passes {@code 0} the configured default hot soft TTL
858
   * ({@link #getEffectiveHotSoftTtlMs()}) is used.
859
   *
860
   * @param cacheKey  the key whose soft expiry should be extended
861
   * @param softTtlMs new soft TTL in milliseconds; {@code 0} to use the
862
   *                  configured hot soft TTL
863
   */
864
  public void extendSoftExpiry(String cacheKey, long softTtlMs) {
865
    long soft = resolveEffectiveHotSoft(softTtlMs);
4✔
866
    extendExpiry(cacheKey, 0, soft, false, true);
7✔
867
  }
1✔
868

869
  /**
870
   * Atomically update the expiry timestamps of an existing cache entry.
871
   *
872
   * @param cacheKey    the key whose expiry should be extended
873
   * @param hardTtlMs   new hard TTL in milliseconds (ignored if {@code updateHard} is false)
874
   * @param softTtlMs   new soft TTL in milliseconds (ignored if {@code updateSoft} is false)
875
   * @param updateHard  whether to update the hard expiry timestamp
876
   * @param updateSoft  whether to update the soft expiry timestamp
877
   */
878
  private void extendExpiry(String cacheKey, long hardTtlMs, long softTtlMs, boolean updateHard, boolean updateSoft) {
879
    caffeineCache
2✔
880
      .asMap()
8✔
881
      .computeIfPresent(cacheKey, (k, existing) -> {
2✔
882
        if (existing instanceof CacheEntry entry) {
6!
883
          if (updateHard) {
2✔
884
            entry = applyHardTtl(entry, resolveEffectiveHardTtl(hardTtlMs));
7✔
885
          }
886
          if (updateSoft) {
2✔
887
            entry = applySoftTtl(entry, resolveEffectiveSoftTtl(softTtlMs));
7✔
888
          }
889
          return entry;
2✔
890
        }
891
        return existing;
×
892
      });
893
  }
1✔
894
}
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