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

Hyshmily / hotkey / 28835396755

07 Jul 2026 01:38AM UTC coverage: 89.623% (-0.7%) from 90.336%
28835396755

push

github

Hyshmily
fix and feat : fix known bugs and accept lz4 to wrap value for smaller memory,"wrap key" needs to consider

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

1325 of 1552 branches covered (85.37%)

Branch coverage included in aggregate %.

167 of 236 new or added lines in 12 files covered. (70.76%)

3745 of 4105 relevant lines covered (91.23%)

4.19 hits per line

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

90.88
/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
  /** Semaphore limiting concurrent background refresh operations (null if soft expire disabled). */
53
  // null when soft expire is disabled; always guarded by isSoftExpireEnabled() check
54
  private final Semaphore refreshLimiter;
55
  /** Per-key dedup for background refreshes — prevents concurrent refresh for the same key. */
56
  private final ConcurrentHashMap<String, CompletableFuture<?>> pendingRefreshes = new ConcurrentHashMap<>();
5✔
57
  /** Compressor for L1 cache values. */
58
  private final CacheCompressor compressor;
59

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

63
  private static final long refreshTimeoutSeconds = 30;
64

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

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

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

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

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

153
  /**
154
   * Whether any soft TTL is configured (normal or hot).
155
   *
156
   * @return {@code true} if soft expire is enabled in the configuration
157
   */
158
  public boolean isSoftExpireEnabled() {
159
    return ttlConfig.isSoftExpireEnabled();
4✔
160
  }
161

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

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

194
  public long computeNullExpireAt(long nullTtlMs) {
195
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
10✔
196
    return toHardExpireTimestamp(effective);
4✔
197
  }
198

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

540
  @Override
541
  public CacheEntry replaceEntryValue(CacheEntry entry, Object newValue) {
542
    return entry.toBuilder().value(compressor.wrap(newValue)).build();
9✔
543
  }
544

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

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

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

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

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

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

637
    return hardTtlMs > 0 ? TimeSource.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
638
  }
639

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

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

670
    return TimeSource.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
671
  }
672

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

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

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

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

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

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

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

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

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

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

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