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

Hyshmily / hotkey / 27925388821

22 Jun 2026 02:14AM UTC coverage: 92.403% (-1.2%) from 93.615%
27925388821

push

github

Hyshmily
fix:fix known bugs and promote performance

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

1143 of 1289 branches covered (88.67%)

Branch coverage included in aggregate %.

184 of 236 new or added lines in 19 files covered. (77.97%)

6 existing lines in 3 files now uncovered.

3321 of 3542 relevant lines covered (93.76%)

4.22 hits per line

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

88.81
/common/src/main/java/io/github/hyshmily/hotkey/cache/HotKeyCache.java
1
/*
2
 * Copyright 2026 Hyshmily. All Rights Reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package io.github.hyshmily.hotkey.cache;
17

18
import static io.github.hyshmily.hotkey.cache.CacheKeysPolicy.invalidCacheKey;
19

20
import com.github.benmanes.caffeine.cache.Cache;
21
import com.github.benmanes.caffeine.cache.stats.CacheStats;
22
import io.github.hyshmily.hotkey.cache.annotationsupporter.NullValue;
23
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
24
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
25
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
26
import io.github.hyshmily.hotkey.model.CacheEntry;
27
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
28
import io.github.hyshmily.hotkey.model.KeyState;
29
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
30
import io.github.hyshmily.hotkey.rule.Rule;
31
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
32
import io.github.hyshmily.hotkey.rule.RuleMatcher;
33
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
34
import io.github.hyshmily.hotkey.sharding.RingManager;
35
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
36
import io.github.hyshmily.hotkey.util.version.VersionController;
37
import io.github.hyshmily.hotkey.util.version.VersionGuard;
38
import jakarta.annotation.Nullable;
39
import java.util.Collection;
40
import java.util.List;
41
import java.util.Optional;
42
import java.util.concurrent.Executor;
43
import java.util.function.Supplier;
44
import lombok.RequiredArgsConstructor;
45
import lombok.extern.slf4j.Slf4j;
46

47
/**
48
 * Core orchestration class for hot-key caching.
49
 * <p>
50
 * Manages L1 (Caffeine) operations with hot-key awareness, dual version tracking
51
 * ({@code dataVersion} for data mutation ordering, {@code decisionVersion} for
52
 * Worker HOT/COOL decision ordering),
53
 * SingleFlight deduplication, soft-expire (stale-while-revalidate), and
54
 * cross-instance synchronization via {@link CacheSyncPublisher}.
55
 * All write operations are transaction-aware via {@link TransactionSupport}.
56
 */
57
@SuppressWarnings("DuplicatedCode")
58
@RequiredArgsConstructor
59
@Slf4j
4✔
60
public class HotKeyCache {
61

62
  /** Local TopK detector (HotKeyDetector) for identifying hot keys. */
63
  private final HotKeyDetector hotKeyDetector;
64
  /** Underlying L1 Caffeine cache storing {@link CacheEntry} or raw values. */
65
  private final Cache<String, Object> caffeineCache;
66
  /** Deduplicator preventing concurrent in-flight loads for the same key. */
67
  private final SingleFlight singleFlight;
68
  /** Manages hard and soft TTL computation for cache entries. */
69
  private final CacheExpireManager expireManager;
70
  /** Executor for async cache operations (promotion, soft refresh). */
71
  private final Executor hotKeyExecutor;
72
  /** Optional publisher for cross-instance cache synchronization. */
73
  private final Optional<CacheSyncPublisher> cacheSyncPublisher;
74
  /** Optional reporter for app-to-Worker hot key reporting. */
75
  private final Optional<HotKeyReporter> hotKeyReporter;
76
  /** Matches cache keys against blacklist/whitelist rules. */
77
  private final RuleMatcher ruleMatcher;
78
  /** Manages data version generation for mutation ordering. */
79
  private final VersionController versionController;
80
  /** Monitors Worker cluster health for graceful degradation decisions. */
81
  private final RingManager ringManager;
82

83
  /** Cached view of Worker cluster health, used for COOL promotion decisions. */
84
  private final ClusterHealthView healthView;
85

86
  /** Log message constant when no sync publisher is available. */
87
  private static final String NO_SYNC_PUBLISHER = HotKeyConstants.NO_SYNC_PUBLISHER;
88

89
  /**
90
   * Check whether a {@link CacheEntry} has logically expired based on its
91
   * {@code hardExpireAtMs}.  Entries with {@code hardExpireAtMs == Long.MAX_VALUE}
92
   * are treated as permanent (never logically expire).
93
   *
94
   * @param entry the cache entry to inspect
95
   * @return {@code true} if the entry has logically expired
96
   */
97
  private static boolean isLogicallyExpired(CacheEntry entry) {
98
    return entry.getHardExpireAtMs() != Long.MAX_VALUE && System.currentTimeMillis() >= entry.getHardExpireAtMs();
14✔
99
  }
100

101
  /**
102
   * Check whether the given raw cache value is a logically expired {@link CacheEntry}
103
   * and, if so, invalidate it and return {@code true}.
104
   * <p>Eliminates code duplication between {@link #get} and {@link #getWithSoftExpire}
105
   * which both perform this check before and after side effects (TOCTOU guard).
106
   *
107
   * @param cacheKey the cache key to invalidate if expired
108
   * @param raw      the raw value from the Caffeine cache
109
   * @return {@code true} if the entry was expired and has been invalidated
110
   */
111
  private boolean invalidateIfIsLogicallyExpired(String cacheKey, Object raw) {
112
    if (raw instanceof CacheEntry ce && isLogicallyExpired(ce)) {
9✔
113
      caffeineCache.invalidate(cacheKey);
4✔
114
      log.debug("Cache entry logically expired during processing, reloading: {}", cacheKey);
4✔
115
      return true;
2✔
116
    }
117
    return false;
2✔
118
  }
119

120
  /**
121
  /**
122
   * Check whether an existing cache entry is managed by the Worker (HOT or COOL).
123
   * Worker-managed entries preserve their original normal TTLs through writes.
124
   *
125
   * @param existing the existing cache entry (maybe {@code null} or a raw value)
126
   * @return {@code true} if the entry is a {@link CacheEntry} with state HOT or COOL
127
   */
128
  private static boolean isWorkerManagedEntry(Object existing) {
129
    return (
1✔
130
      existing instanceof CacheEntry entry &&
7✔
131
      (entry.getKeyState() == KeyState.HOT || entry.getKeyState() == KeyState.COOL)
6!
132
    );
133
  }
134

135
  /**
136
   * Check whether a key is currently tracked as a local hot key in L1.
137
   *
138
   * @param cacheKey the key to inspect
139
   * @return {@code true} if the key exists in L1 with {@link KeyState#HOT}
140
   */
141
  public boolean isLocalHotKey(String cacheKey) {
142
    if (invalidCacheKey(cacheKey)) {
3✔
143
      log.debug("isLocalHotKey: invalid cacheKey");
3✔
144
      return false;
2✔
145
    }
146
    Object entry = caffeineCache.getIfPresent(cacheKey);
5✔
147
    if (entry instanceof CacheEntry ce) {
6✔
148
      if (isLogicallyExpired(ce)) {
3✔
149
        return false;
2✔
150
      }
151
      return KeyState.HOT == ce.getKeyState();
8✔
152
    }
153
    // Fallback to HeavyKeeper for keys that are hot in the detection engine but not yet
154
    // promoted to L1 with a CacheEntry wrapper (e.g., during the first detection window).
155
    return hotKeyDetector.contains(cacheKey);
5✔
156
  }
157

158
  /**
159
   * Whether the given {@link KeyState} is eligible for local promotion to HOT.
160
   * <p>
161
   * {@link KeyState#NORMAL} entries are always eligible: when the local TopK
162
   * detects them as hot, they get promoted to HOT with longer TTLs.
163
   * <p>
164
   * {@link KeyState#COOL} entries are only eligible when no Worker shard is
165
   * alive ( returns {@code false}).
166
   * This provides graceful degradation — when the Worker cluster is unavailable,
167
   * the local TopK drives TTL decisions instead of preserving stale Worker verdicts.
168
   * Once a Worker comes back online and broadcasts a new decision, it overrides
169
   * the local promotion via {@code decisionVersion} comparison.
170
   * <p>
171
   * {@link KeyState#HOT} entries are never eligible — they already have the
172
   * longest TTLs.
173
   *
174
   * @param state the current key state of the cache entry
175
   * @return {@code true} if the entry may be promoted by local TopK
176
   */
177
  private boolean isPromotableState(KeyState state) {
178
    return state == KeyState.NORMAL || (state == KeyState.COOL && !healthView.isClusterHealthy());
14✔
179
  }
180

181
  /**
182
   * Look up a cached value without loading or triggering hot-key detection.
183
   * Unlike {@link #get}, this method never invokes the reader or SingleFlight.
184
   *
185
   * @param cacheKey the key to inspect
186
   * @return an {@link Optional} containing the raw value if present
187
   */
188
  @SuppressWarnings("unchecked")
189
  public <T> Optional<T> peek(String cacheKey) {
190
    if (invalidCacheKey(cacheKey)) {
3✔
191
      log.debug("peek: invalid cacheKey");
3✔
192
      return Optional.empty();
2✔
193
    }
194

195
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
196
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
197
    }
198

199
    try {
200
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey)).map(raw ->
8✔
201
        raw instanceof CacheEntry vv ? (T) unwrapNull(vv.getValue()) : (T) raw
12✔
202
      );
NEW
203
    } catch (HotKeyBlockedException e) {
×
NEW
204
      throw e;
×
NEW
205
    } catch (RuntimeException e) {
×
NEW
206
      log.error("HotKeyCache.peek internal error for key={}, returning empty", cacheKey, e);
×
NEW
207
      return Optional.empty();
×
208
    }
209
  }
210

211
  /**
212
   * Get a value from L1 or load it via the reader.
213
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
214
   *
215
   * @param cacheKey the key to retrieve
216
   * @param reader   the value supplier for cache misses
217
   * @param <T>      the value type
218
   * @return an {@link Optional} containing the cached or loaded value
219
   * @throws HotKeyBlockedException when the key matches a blacklist rule
220
   */
221
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
222
    return get(cacheKey, reader, 0L, 0L);
7✔
223
  }
224

225
  /**
226
   * Get with explicit TTL overrides.
227
   * Pass 0 to use the configured default for that TTL type.
228
   *
229
   * @param cacheKey  the key to retrieve
230
   * @param reader    the value supplier for cache misses
231
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
232
   * @param softTtlMs soft TTL override (0 = use configured default)
233
   * @param <T>       the value type
234
   * @return an {@link Optional} containing the cached or loaded value
235
   * @throws HotKeyBlockedException when the key matches a blacklist rule
236
   */
237
  @SuppressWarnings("unchecked")
238
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
239
    // Graceful degradation: invalid cache key is a caller-side issue, not a HotKey internal
240
    // failure. Return empty rather than throwing to keep callers operational.
241
    if (invalidCacheKey(cacheKey)) {
3✔
242
      log.debug("get: invalid cacheKey");
3✔
243
      return Optional.empty();
2✔
244
    }
245

246
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
247
    if (action == RuleAction.BLOCK) {
3✔
248
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
249
    }
250
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
251

252
    try {
253
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
254
        .flatMap(raw -> {
8✔
255
          if (invalidateIfIsLogicallyExpired(cacheKey, raw)) {
5✔
256
            return Optional.empty();
2✔
257
          }
258

259
          T val = raw instanceof CacheEntry vv ? (T) unwrapNull(vv.getValue()) : (T) raw;
12✔
260

261
          boolean wasPromoted = promoteLocalHotkeyIfNeeded(cacheKey, raw, val, hardTtlMs, softTtlMs);
8✔
262
          if (!skipReport) {
2✔
263
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
264
          }
265

266
          // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
267
          if (wasPromoted || !skipReport) {
4✔
268
            Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
269
            if (invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
5!
NEW
270
              return Optional.empty();
×
271
            }
272
          }
273

274
          return Optional.ofNullable(val);
3✔
275
        })
276
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
NEW
277
    } catch (HotKeyBlockedException e) {
×
NEW
278
      throw e;
×
NEW
279
    } catch (RuntimeException e) {
×
NEW
280
      log.error("HotKeyCache.get internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
NEW
281
      return Optional.empty();
×
282
    }
283
  }
284

285
  /**
286
   * Get with soft-expire (stale-while-revalidate). Returns cached value immediately
287
   * even if soft TTL expired, while triggering async refresh in background.
288
   * Only HOT and COOL entries are subject to soft expire.
289
   *
290
   * @param cacheKey the key to retrieve
291
   * @param reader   the value supplier for cache misses / refreshes
292
   * @param <T>      the value type
293
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
294
   * @throws HotKeyBlockedException when the key matches a blacklist rule
295
   */
296
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader) {
297
    return getWithSoftExpire(cacheKey, reader, 0L, 0L);
7✔
298
  }
299

300
  /**
301
   * Get with soft-expire and explicit soft TTL override.
302
   *
303
   * @param cacheKey  the key to retrieve
304
   * @param reader    the value supplier for cache misses / refreshes
305
   * @param softTtlMs soft TTL override (0 = use configured default)
306
   * @param <T>       the value type
307
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
308
   * @throws HotKeyBlockedException when the key matches a blacklist rule
309
   */
310
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
311
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
312
  }
313

314
  /**
315
   * Get with soft-expire and explicit hard/soft TTL overrides.
316
   *
317
   * @param cacheKey  the key to retrieve
318
   * @param reader    the value supplier for cache misses / refreshes
319
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for pure logical expiry — entry never hard-evicted, only soft-expire or Caffeine {@code maximumSize})
320
   * @param softTtlMs soft TTL override (0 = use configured default)
321
   * @param <T>       the value type
322
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
323
   * @throws HotKeyBlockedException when the key matches a blacklist rule
324
   */
325
  @SuppressWarnings("unchecked")
326
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
327
    if (invalidCacheKey(cacheKey)) {
3✔
328
      log.debug("getWithSoftExpire: invalid cacheKey");
3✔
329
      return Optional.empty();
2✔
330
    }
331

332
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
333
    if (action == RuleAction.BLOCK) {
3✔
334
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
335
    }
336
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
337

338
    if (!expireManager.isSoftExpireEnabled()) {
4✔
339
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
340
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
341
    }
342
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
343
    try {
344
      return Optional.ofNullable(raw)
11✔
345
        .flatMap(v -> {
8✔
346
          if (invalidateIfIsLogicallyExpired(cacheKey, raw)) {
5✔
347
            return Optional.empty();
2✔
348
          }
349

350
          T cached = v instanceof CacheEntry vv ? (T) unwrapNull(vv.getValue()) : (T) v;
12✔
351

352
          if (
3✔
353
            v instanceof CacheEntry cacheEntry &&
5✔
354
            (KeyState.HOT == cacheEntry.getKeyState() || KeyState.COOL == cacheEntry.getKeyState())
6✔
355
          ) {
356
            if (expireManager.isSoftExpired(cacheEntry)) {
5✔
357
              long effectiveSoft =
358
                softTtlMs > 0
4✔
359
                  ? softTtlMs
2✔
360
                  : (KeyState.HOT == cacheEntry.getKeyState()
4✔
361
                      ? expireManager.getEffectiveHotSoftTtlMs()
4✔
362
                      : cacheEntry.getNormalSoftTtlMs() > 0
5✔
363
                        ? cacheEntry.getNormalSoftTtlMs()
3✔
364
                        : expireManager.getEffectiveSoftTtlMs());
4✔
365

366
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
367
            }
368
          }
369

370
          boolean wasPromoted = promoteLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
371
          if (!skipReport) {
2✔
372
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
373
          }
374

375
          // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
376
          if (wasPromoted || !skipReport) {
4!
377
            Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
378
            if (invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
5!
NEW
379
              return Optional.empty();
×
380
            }
381
          }
382

383
          return Optional.ofNullable(cached);
3✔
384
        })
385
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
NEW
386
    } catch (HotKeyBlockedException e) {
×
NEW
387
      throw e;
×
NEW
388
    } catch (RuntimeException e) {
×
NEW
389
      log.error(
×
390
        "HotKeyCache.getWithSoftExpire internal error for key={}, returning empty to keep caller operational",
391
        cacheKey,
392
        e
393
      );
NEW
394
      return Optional.empty();
×
395
    }
396
  }
397

398
  /**
399
   * Load via SingleFlight, detect hot key, cache with HOT or NORMAL TTL, and return value.
400
   *
401
   * @param cacheKey   the key to load
402
   * @param reader     the value supplier
403
   * @param hardTtlMs  hard TTL override (0 = use configured default)
404
   * @param softTtlMs  soft TTL override (0 = use configured default)
405
   * @param skipReport if {@code true}, skip reporting to Worker
406
   */
407
  private <T> Optional<T> loadAndCache(
408
    String cacheKey,
409
    Supplier<T> reader,
410
    long hardTtlMs,
411
    long softTtlMs,
412
    boolean skipReport
413
  ) {
414
    // Secondary check: rule may have been added after the entry check in get/getWithSoftExpire
415
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
416
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
417
    }
418
    return singleFlight
5✔
419
      .load(cacheKey, reader)
7✔
420
      .map(value -> {
1✔
421
        if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
422
          throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
423
        }
424

425
        long effectiveHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
426
        long effectiveSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
427

428
        hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
429
        if (hotKeyDetector.contains(cacheKey)) {
5✔
430
          long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
10✔
431
          long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
10✔
432
          long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
433
          long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
434

435
          caffeineCache
2✔
436
            .asMap()
10✔
437
            .compute(cacheKey, (k, existing) -> {
2✔
438
              if (isWorkerManagedEntry(existing)) {
3✔
439
                return existing;
2✔
440
              }
441
              return CacheEntry.builder()
3✔
442
                .value(value)
2✔
443
                .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
444
                .isVersionDegraded(false)
2✔
445
                .decisionVersion(0L)
2✔
446
                .hardTtlMs(hotHard)
2✔
447
                .hardExpireAtMs(hotHardExpireAtMs)
2✔
448
                .softTtlMs(hotSoft)
2✔
449
                .softExpireAtMs(hotSoftExpireAtMs)
2✔
450
                .keyState(KeyState.HOT)
2✔
451
                .normalHardTtlMs(effectiveHard)
2✔
452
                .normalSoftTtlMs(effectiveSoft)
1✔
453
                .build();
1✔
454
            });
455

456
          if (!skipReport) {
2✔
457
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
458
          }
459
          log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
460
        } else {
1✔
461
          long effectiveHardExpireAtMs = expireManager.computeHardExpireAt(effectiveHard);
5✔
462
          long effectiveSoftExpireAtMs = expireManager.computeSoftExpireAt(effectiveSoft);
5✔
463

464
          caffeineCache
2✔
465
            .asMap()
8✔
466
            .compute(cacheKey, (k, existing) -> {
2✔
467
              if (isWorkerManagedEntry(existing)) {
3✔
468
                return existing;
2✔
469
              }
470
              return CacheEntry.builder()
3✔
471
                .value(value)
2✔
472
                .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
473
                .isVersionDegraded(false)
2✔
474
                .decisionVersion(0L)
2✔
475
                .hardTtlMs(effectiveHard)
2✔
476
                .hardExpireAtMs(effectiveHardExpireAtMs)
2✔
477
                .softTtlMs(effectiveSoft)
2✔
478
                .softExpireAtMs(effectiveSoftExpireAtMs)
2✔
479
                .keyState(KeyState.NORMAL)
2✔
480
                .normalHardTtlMs(effectiveHard)
2✔
481
                .normalSoftTtlMs(effectiveSoft)
1✔
482
                .build();
1✔
483
            });
484

485
          if (!skipReport) {
2✔
486
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
487
          }
488
          log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
489
        }
490
        return value;
2✔
491
      });
492
  }
493

494
  /**
495
   * Promotes a non-hot entry to HOT state in L1 if the local TopK detector
496
   * now considers it a hot key. Preserves existing version and TTL metadata.
497
   * {@link KeyState#NORMAL} entries are always eligible; {@link KeyState#COOL}
498
   * entries are only eligible when all Workers are dead (graceful fallback).
499
   *
500
   * @param cacheKey  the key to promote
501
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
502
   * @param val       the extracted value from the cache entry
503
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
504
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
505
   */
506
  private boolean promoteLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
507
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
508
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
509
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
510
        return false;
2✔
511
      }
512
      long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
8!
513
      long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
8!
514
      long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
515
      long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
516

517
      caffeineCache
2✔
518
        .asMap()
10✔
519
        .compute(cacheKey, (k, existing) -> {
2✔
520
          if (existing instanceof CacheEntry entry) {
6!
521
            if (!isPromotableState(entry.getKeyState())) {
5!
522
              return existing;
×
523
            }
524

525
            return entry
2✔
526
              .toBuilder()
2✔
527
              .hardTtlMs(hotHard)
2✔
528
              .softTtlMs(hotSoft)
2✔
529
              .hardExpireAtMs(hotHardExpireAtMs)
2✔
530
              .softExpireAtMs(hotSoftExpireAtMs)
2✔
531
              .keyState(KeyState.HOT)
1✔
532
              .build();
1✔
533
          }
534

535
          return CacheEntry.builder()
×
536
            .value(val)
×
537
            .dataVersion(ce.getDataVersion())
×
538
            .isVersionDegraded(ce.isVersionDegraded())
×
539
            .decisionVersion(ce.getDecisionVersion())
×
540
            .hardTtlMs(hotHard)
×
541
            .hardExpireAtMs(hotHardExpireAtMs)
×
542
            .softTtlMs(hotSoft)
×
543
            .softExpireAtMs(hotSoftExpireAtMs)
×
544
            .keyState(KeyState.HOT)
×
545
            .normalHardTtlMs(ce.getNormalHardTtlMs())
×
546
            .normalSoftTtlMs(ce.getNormalSoftTtlMs())
×
547
            .build();
×
548
        });
549
      return true;
2✔
550
    }
551
    return false;
2✔
552
  }
553

554
  /**
555
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
556
   * so they remove their local copy and re-fetch on next {@link #get}.
557
   * The next local {@link #get} will re-fetch from the reader.
558
   *
559
   * @param cacheKey the key to invalidate
560
   */
561
  public void invalidate(String cacheKey) {
562
    if (invalidCacheKey(cacheKey)) {
3✔
563
      log.debug("invalidate: invalid cacheKey");
3✔
564
      return;
1✔
565
    }
566

567
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
568
      var vr = versionController.nextVersion(cacheKey);
5✔
569
      caffeineCache.invalidate(cacheKey);
4✔
570
      cacheSyncPublisher.ifPresentOrElse(
7✔
571
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
572
        () -> log.debug("invalidate: " + NO_SYNC_PUBLISHER)
4✔
573
      );
574
    });
1✔
575
  }
1✔
576

577
  /**
578
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
579
   * Invalid keys (null or blank) are silently skipped.
580
   *
581
   * @param cacheKeys the keys to invalidate
582
   */
583
  public void invalidateAll(Collection<String> cacheKeys) {
584
    List<String> validKeys = cacheKeys
1✔
585
      .stream()
2✔
586
      .filter(k -> !invalidCacheKey(k))
8✔
587
      .toList();
2✔
588
    if (validKeys.isEmpty()) {
3✔
589
      log.debug("invalidateAll: all cacheKeys are invalid");
3✔
590
      return;
1✔
591
    }
592

593
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
594
      caffeineCache.invalidateAll(validKeys);
4✔
595
      cacheSyncPublisher.ifPresentOrElse(
7✔
596
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
597
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
598
      );
599
    });
1✔
600
  }
1✔
601

602
  /**
603
   * Evict keys from the local cache only, without broadcasting to other
604
   * instances and without bumping version numbers.
605
   *
606
   * <p>Useful for emergency local cleanup, testing, or when a module is
607
   * taken offline and only the current node needs to be cleared.
608
   *
609
   * @param cacheKeys the keys to evict locally
610
   */
611
  public void evictLocal(Collection<String> cacheKeys) {
612
    List<String> validKeys = cacheKeys
1✔
613
      .stream()
2✔
614
      .filter(k -> !invalidCacheKey(k))
8✔
615
      .toList();
2✔
616
    if (validKeys.isEmpty()) {
3✔
617
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
618
      return;
1✔
619
    }
620
    caffeineCache.invalidateAll(validKeys);
4✔
621
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
622
  }
1✔
623

624
  /**
625
   * Write-through: execute the writer, then update L1 and broadcast.
626
   * Uses effective hard/soft TTL from configuration.
627
   *
628
   * @param cacheKey the key to write
629
   * @param value    the value to cache
630
   * @param writer   the data-source mutation to execute before caching
631
   * @param <T>      the value type
632
   * @throws HotKeyBlockedException when the key matches a blacklist rule
633
   */
634
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
635
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
636
  }
1✔
637

638
  /**
639
   * Write-through with explicit TTL overrides.
640
   * Pass 0 to use the configured default for that TTL type.
641
   *
642
   * @param cacheKey  the key to write
643
   * @param value     the value to cache
644
   * @param writer    the data-source mutation to execute before caching
645
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
646
   * @param softTtlMs soft TTL override (0 = use configured default)
647
   * @param <T>       the value type
648
   */
649
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
650
    if (invalidCacheKey(cacheKey)) {
3✔
651
      log.debug("putThrough: invalid cacheKey");
3✔
652
      return;
1✔
653
    }
654
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
655
      log.debug("putThrough: blocked by rule: {}", cacheKey);
4✔
656
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
657
    }
658
    TransactionSupport.runAsyncAfterCommit(
10✔
659
      () -> {
660
        try {
661
          writer.run();
2✔
NEW
662
        } catch (Exception e) {
×
NEW
663
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
NEW
664
          return;
×
665
        }
1✔
666
        var vr = versionController.nextVersion(cacheKey);
5✔
667

668
        long effectiveHardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
669
        long effectiveSoftTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
670

671
        caffeineCache
2✔
672
          .asMap()
8✔
673
          .compute(cacheKey, (k, existing) ->
2✔
674
            buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
675
          );
676

677
        cacheSyncPublisher.ifPresentOrElse(
7✔
678
          p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
679
          () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
680
        );
681
      },
1✔
682
      hotKeyExecutor
683
    );
684
  }
1✔
685

686
  /**
687
   * Builds a {@link CacheEntry} for a write-through operation, preserving
688
   * Worker-managed state and version information from the existing entry.
689
   *
690
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
691
   * @param value             the new value to cache
692
   * @param vr                the version result from the version controller
693
   * @param effectiveHardTtl  the effective hard TTL to use
694
   * @param effectiveSoftTtl  the effective soft TTL to use
695
   * @param <T>               the value type
696
   * @return a new {@link CacheEntry} with the supplied value and metadata
697
   */
698
  private <T> CacheEntry buildPutThroughEntry(
699
    Object existing,
700
    T value,
701
    VersionController.VersionResult vr,
702
    long effectiveHardTtl,
703
    long effectiveSoftTtl
704
  ) {
705
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
706
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13!
707
      return ce;
2✔
708
    }
709

710
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
711

712
    long normalHardTtl = 0;
2✔
713
    long normalSoftTtl = 0;
2✔
714

715
    long decisionVersion = (existing instanceof CacheEntry entry) ? entry.getDecisionVersion() : 0L;
5!
716
    String decisionNodeId = (existing instanceof CacheEntry entry) ? entry.getDecisionNodeId() : null;
5!
717
    long decisionEpoch = (existing instanceof CacheEntry entry) ? entry.getDecisionEpoch() : 0L;
5!
718

719
    if (existing instanceof CacheEntry) {
3!
UNCOV
720
      normalHardTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalHardTtlMs() : effectiveHardTtl;
×
721
    }
722
    if (existing instanceof CacheEntry) {
3!
UNCOV
723
      normalSoftTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalSoftTtlMs() : effectiveSoftTtl;
×
724
    }
725

726
    return CacheEntry.builder()
2✔
727
      .value(value != null ? value : NullValue.INSTANCE)
7✔
728
      .dataVersion(vr.dataVersion())
3✔
729
      .isVersionDegraded(vr.degraded())
3✔
730
      .decisionVersion(decisionVersion)
2✔
731
      .decisionNodeId(decisionNodeId)
2✔
732
      .decisionEpoch(decisionEpoch)
1✔
733
      .hardTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotHardTtlMs() : effectiveHardTtl)
5!
734
      .hardExpireAtMs(
1✔
735
        state == KeyState.HOT
3!
UNCOV
736
          ? expireManager.computeHotHardExpireAt()
×
737
          : expireManager.computeHardExpireAt(effectiveHardTtl)
4✔
738
      )
739
      .softTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotSoftTtlMs() : effectiveSoftTtl)
5!
740
      .softExpireAtMs(
2✔
741
        state == KeyState.HOT
3!
UNCOV
742
          ? expireManager.computeHotSoftExpireAt()
×
743
          : expireManager.computeSoftExpireAt(effectiveSoftTtl)
4✔
744
      )
745
      .keyState(state)
2✔
746
      .normalHardTtlMs(normalHardTtl)
2✔
747
      .normalSoftTtlMs(normalSoftTtl)
1✔
748
      .build();
1✔
749
  }
750

751
  /**
752
   * Write a value directly into the local L1 cache without version bump,
753
   * without broadcast, without hot-key detection, and without reporting.
754
   * <p>
755
   * Existing entry metadata is preserved.  If no entry exists, a fresh
756
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
757
   * <p>
758
   * Pass {@code 0} for either TTL to use the configured default.
759
   *
760
   * @param cacheKey  the key to store
761
   * @param value     the value to cache
762
   * @param hardTtlMs hard TTL override (0 = use configured default)
763
   * @param softTtlMs soft TTL override (0 = use configured default)
764
   */
765
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
766
    if (invalidCacheKey(cacheKey)) {
3✔
767
      log.debug("putLocal: invalid cacheKey");
3✔
768
      return;
1✔
769
    }
770
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
771
      log.debug("putLocal: blocked by rule: {}", cacheKey);
4✔
772
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
773
    }
774

775
    long hardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
776
    long softTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
777

778
    caffeineCache
2✔
779
      .asMap()
7✔
780
      .compute(cacheKey, (k, existing) -> {
2✔
781
        if (existing instanceof CacheEntry ce) {
6✔
782
          return ce
2✔
783
            .toBuilder()
2✔
784
            .value(value)
2✔
785
            .hardTtlMs(hardTtl)
2✔
786
            .softTtlMs(softTtl)
4✔
787
            .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
5✔
788
            .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
2✔
789
            .build();
1✔
790
        }
791
        return CacheEntry.builder()
3✔
792
          .value(value)
2✔
793
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
794
          .isVersionDegraded(false)
2✔
795
          .decisionVersion(0L)
2✔
796
          .hardTtlMs(hardTtl)
4✔
797
          .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
3✔
798
          .softTtlMs(softTtl)
4✔
799
          .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
3✔
800
          .keyState(KeyState.NORMAL)
2✔
801
          .normalHardTtlMs(hardTtl)
2✔
802
          .normalSoftTtlMs(softTtl)
1✔
803
          .build();
1✔
804
      });
805
  }
1✔
806

807
  /**
808
   * Execute a mutation, then invalidate L1 and broadcast.
809
   * Next {@link #get} will re-fetch from the reader.
810
   *
811
   * @param cacheKey the key to invalidate after mutation
812
   * @param mutation the mutation to execute
813
   */
814
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
815
    if (invalidCacheKey(cacheKey)) {
3✔
816
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
817
      return;
1✔
818
    }
819
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
820
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
821
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
822
    }
823
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
824
      try {
825
        mutation.run();
2✔
826
      } catch (Exception e) {
1✔
827
        log.error("putBeforeInvalidate failed, skip local invalidate and broadcast: {}", cacheKey, e);
5✔
828
        return;
1✔
829
      }
1✔
830
      var vr = versionController.nextVersion(cacheKey);
5✔
831
      caffeineCache.invalidate(cacheKey);
4✔
832

833
      cacheSyncPublisher.ifPresentOrElse(
7✔
834
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
835
        () -> log.debug("putBeforeInvalidate: {}", NO_SYNC_PUBLISHER)
5✔
836
      );
837
    });
1✔
838
  }
1✔
839

840
  /**
841
   * Add a key pattern to the blacklist.
842
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
843
   *
844
   * @param cacheKey the key pattern to blacklist
845
   */
846
  public void addBlacklist(String cacheKey) {
847
    if (invalidCacheKey(cacheKey)) {
3✔
848
      log.debug("blacklist: invalid cacheKey");
3✔
849
      return;
1✔
850
    }
851
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK)));
11✔
852
  }
1✔
853

854
  /**
855
   * Add a key pattern to the whitelist.
856
   * Matching keys are allowed but bypass app-to-Worker reporting.
857
   *
858
   * @param cacheKey the key pattern to whitelist
859
   */
860
  public void addWhitelist(String cacheKey) {
861
    if (invalidCacheKey(cacheKey)) {
3✔
862
      log.debug("whitelist: invalid cacheKey");
3✔
863
      return;
1✔
864
    }
865
    TransactionSupport.runNowOrAfterCommit(() ->
4✔
866
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT))
7✔
867
    );
868
  }
1✔
869

870
  /**
871
   * Remove a key pattern from the blacklist.
872
   *
873
   * @param cacheKey the key pattern to remove from the blacklist
874
   */
875
  public void unBlacklist(String cacheKey) {
876
    if (invalidCacheKey(cacheKey)) {
3✔
877
      log.debug("unblacklist: invalid cacheKey");
3✔
878
      return;
1✔
879
    }
880
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK));
11✔
881
  }
1✔
882

883
  /**
884
   * Remove a key pattern from the whitelist.
885
   *
886
   * @param cacheKey the key pattern to remove from the whitelist
887
   */
888
  public void unWhitelist(String cacheKey) {
889
    if (invalidCacheKey(cacheKey)) {
3✔
890
      log.debug("unwhitelist: invalid cacheKey");
3✔
891
      return;
1✔
892
    }
893
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT));
11✔
894
  }
1✔
895

896
  /**
897
   * Evaluate all rules against the given key and return the first matching action.
898
   *
899
   * @param cacheKey the key to evaluate
900
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
901
   */
902
  public RuleAction evaluateRule(String cacheKey) {
903
    return ruleMatcher.evaluateRule(cacheKey);
5✔
904
  }
905

906
  /**
907
   * Return a snapshot of all current rules in evaluation order.
908
   *
909
   * @return list of rules (immutable snapshot)
910
   */
911
  public List<Rule> getAllRules() {
912
    return ruleMatcher.getAllRules();
4✔
913
  }
914

915
  /**
916
   * Remove all blacklist and whitelist rules.
917
   */
918
  public void clearAllRules() {
919
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
920
  }
1✔
921

922
  /**
923
   * Broadcast all local rules to peer instances via the sync exchange.
924
   * Useful for initial synchronization when a new instance joins the cluster.
925
   */
926
  public void broadcastAllLocalRulesManually() {
927
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
928
  }
1✔
929

930
  /**
931
   * Estimated number of entries currently in the L1 cache.
932
   *
933
   * @return best-effort estimate of the current entry count
934
   */
935
  public long estimatedSize() {
936
    return caffeineCache.estimatedSize();
4✔
937
  }
938

939
  /**
940
   * Return a snapshot of basic L1 cache statistics.
941
   * <p>
942
   * Hit/miss/eviction counters are populated only when Caffeine's
943
   * {@code recordStats()} is enabled.  {@code estimatedSize} is always
944
   * available.
945
   *
946
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
947
   *         if stats recording is not enabled
948
   */
949
  public HotKeyCacheStats stats() {
950
    CacheStats cs = caffeineCache.stats();
4✔
951
    return new HotKeyCacheStats(
4✔
952
      cs.hitCount(),
2✔
953
      cs.missCount(),
2✔
954
      cs.hitRate(),
2✔
955
      cs.evictionCount(),
3✔
956
      caffeineCache.estimatedSize()
2✔
957
    );
958
  }
959

960
  /**
961
   * Check whether the given key is blacklisted.
962
   *
963
   * @param cacheKey the key to check
964
   * @return {@code true} if a blacklist rule matches the key
965
   */
966

967
  public boolean isBlacklisted(String cacheKey) {
968
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
969
  }
970

971
  /**
972
   * Check whether the given key is whitelisted (skips Worker reporting).
973
   *
974
   * @param cacheKey the key to check
975
   * @return {@code true} if a whitelist rule matches the key
976
   */
977
  public boolean isWhitelisted(String cacheKey) {
978
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
979
  }
980

981
  /**
982
   * Invalidate all entries from the L1 cache without broadcasting.
983
   * <p>
984
   * This is an emergency flush — all cached values are removed immediately.
985
   * No cross-instance sync messages are sent.
986
   */
987
  public void invalidateAll() {
988
    caffeineCache.invalidateAll();
3✔
989
  }
1✔
990

991
  /**
992
   * Return the underlying Caffeine cache for direct access.
993
   *
994
   * <p>This provides access to Caffeine-specific operations such as
995
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
996
   * caution — bypassing the HotKey orchestration layer (version tracking,
997
   * broadcast, expiry management) can lead to inconsistent state.
998
   *
999
   * @return the raw Caffeine {@link Cache} instance
1000
   */
1001
  public Cache<String, Object> getLocalCache() {
1002
    return caffeineCache;
3✔
1003
  }
1004

1005
  /**
1006
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1007
   * <p>
1008
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1009
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1010
   * transparently returned as {@code null} to callers.
1011
   *
1012
   * @param value the raw value from a {@link CacheEntry}
1013
   * @return {@code null} if the sentinel, otherwise the original value
1014
   */
1015
  @Nullable
1016
  private static Object unwrapNull(@Nullable Object value) {
1017
    return value == NullValue.INSTANCE ? null : value;
7✔
1018
  }
1019
}
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