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

Hyshmily / hotkey / 28216525937

26 Jun 2026 04:08AM UTC coverage: 91.258% (-1.4%) from 92.707%
28216525937

push

github

Hyshmily
feat: add circuit breaker, null-value caching, and heartbeat refinements

- Introduce HotKeyCircuitBreaker with sliding-window failure detection
- Cache null/miss results with configurable TTL (null-value-ttl-seconds)
- Add per-Worker exponential backoff to heartbeat verifier (verify-max-backoff-ms)
- Support min-alive-workers config for cluster health threshold
- Migrate HotKeyEndpoint from Actuator @Endpoint to Spring MVC @RestController
- Add NumberFormatException handling in StateMachineEndpoint

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

1226 of 1419 branches covered (86.4%)

Branch coverage included in aggregate %.

181 of 229 new or added lines in 12 files covered. (79.04%)

1 existing line in 1 file now uncovered.

3503 of 3763 relevant lines covered (93.09%)

4.22 hits per line

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

87.28
/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.autoconfigure.HotKeyProperties;
23
import io.github.hyshmily.hotkey.cache.annotationsupporter.NullValue;
24
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
25
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
26
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
27
import io.github.hyshmily.hotkey.model.CacheEntry;
28
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
29
import io.github.hyshmily.hotkey.model.KeyState;
30
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
31
import io.github.hyshmily.hotkey.rule.Rule;
32
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
33
import io.github.hyshmily.hotkey.rule.RuleMatcher;
34
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
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.concurrent.TimeUnit;
44
import java.util.function.Supplier;
45
import lombok.RequiredArgsConstructor;
46
import lombok.extern.slf4j.Slf4j;
47

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

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

82
  /** HotKey configuration properties (TTL overrides, null-value TTL). */
83
  private final HotKeyProperties hotKeyProperties;
84

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

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

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

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

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

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

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

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

198
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
199
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
200
    }
201

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

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

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

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

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

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

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

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

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

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

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

317
  /**
318
   * Get with soft-expire and explicit hard/soft TTL overrides.
319
   *
320
   * @param cacheKey  the key to retrieve
321
   * @param reader    the value supplier for cache misses / refreshes
322
   * @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})
323
   * @param softTtlMs soft TTL override (0 = use configured default)
324
   * @param <T>       the value type
325
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
326
   * @throws HotKeyBlockedException when the key matches a blacklist rule
327
   */
328
  @SuppressWarnings("unchecked")
329
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
330
    if (invalidCacheKey(cacheKey)) {
3✔
331
      log.debug("getWithSoftExpire: invalid cacheKey");
3✔
332
      return Optional.empty();
2✔
333
    }
334

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

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

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

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

369
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
370
            }
371
          }
372

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

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

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

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

424
    if (result.isEmpty()) {
3!
NEW
425
      if (singleFlight.isBreakerOpen()) {
×
NEW
426
        Object stale = caffeineCache.getIfPresent(cacheKey);
×
427

NEW
428
        if (stale != null) {
×
NEW
429
          T val = stale instanceof CacheEntry ce ? (T) unwrapNull(ce.getValue()) : (T) stale;
×
NEW
430
          log.debug("CB open, returning stale entry for key={}", cacheKey);
×
NEW
431
          return Optional.ofNullable(val);
×
432
        }
433
      }
NEW
434
      long nullTtlMs = TimeUnit.SECONDS.toMillis(hotKeyProperties.getNullValueTtlSeconds());
×
NEW
435
      long nullExpireAtMs = expireManager.computeNullExpireAt(nullTtlMs);
×
436

NEW
437
      caffeineCache.put(
×
438
        cacheKey,
NEW
439
        CacheEntry.builder()
×
NEW
440
          .value(NullValue.INSTANCE)
×
NEW
441
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
×
NEW
442
          .isVersionDegraded(false)
×
NEW
443
          .decisionVersion(0L)
×
NEW
444
          .hardTtlMs(nullTtlMs)
×
NEW
445
          .hardExpireAtMs(nullExpireAtMs)
×
NEW
446
          .softTtlMs(0)
×
NEW
447
          .softExpireAtMs(0)
×
NEW
448
          .keyState(KeyState.NORMAL)
×
NEW
449
          .build()
×
450
      );
NEW
451
      return Optional.empty();
×
452
    }
453
    return result.map(value -> {
9✔
454
      if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
NEW
455
        throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
456
      }
457

458
      long effectiveHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
459
      long effectiveSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
460

461
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
462
      if (hotKeyDetector.contains(cacheKey)) {
5✔
463
        long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
10✔
464
        long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
10✔
465
        long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
466
        long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
467

468
        caffeineCache
2✔
469
          .asMap()
10✔
470
          .compute(cacheKey, (k, existing) -> {
2✔
471
            if (isWorkerManagedEntry(existing)) {
3✔
472
              return existing;
2✔
473
            }
474
            return CacheEntry.builder()
3✔
475
              .value(value)
2✔
476
              .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
477
              .isVersionDegraded(false)
2✔
478
              .decisionVersion(0L)
2✔
479
              .hardTtlMs(hotHard)
2✔
480
              .hardExpireAtMs(hotHardExpireAtMs)
2✔
481
              .softTtlMs(hotSoft)
2✔
482
              .softExpireAtMs(hotSoftExpireAtMs)
2✔
483
              .keyState(KeyState.HOT)
2✔
484
              .normalHardTtlMs(effectiveHard)
2✔
485
              .normalSoftTtlMs(effectiveSoft)
1✔
486
              .build();
1✔
487
          });
488

489
        if (!skipReport) {
2✔
490
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
491
        }
492
        log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
493
      } else {
1✔
494
        long effectiveHardExpireAtMs = expireManager.computeHardExpireAt(effectiveHard);
5✔
495
        long effectiveSoftExpireAtMs = expireManager.computeSoftExpireAt(effectiveSoft);
5✔
496

497
        caffeineCache
2✔
498
          .asMap()
8✔
499
          .compute(cacheKey, (k, existing) -> {
2✔
500
            if (isWorkerManagedEntry(existing)) {
3✔
501
              return existing;
2✔
502
            }
503
            return CacheEntry.builder()
3✔
504
              .value(value)
2✔
505
              .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
506
              .isVersionDegraded(false)
2✔
507
              .decisionVersion(0L)
2✔
508
              .hardTtlMs(effectiveHard)
2✔
509
              .hardExpireAtMs(effectiveHardExpireAtMs)
2✔
510
              .softTtlMs(effectiveSoft)
2✔
511
              .softExpireAtMs(effectiveSoftExpireAtMs)
2✔
512
              .keyState(KeyState.NORMAL)
2✔
513
              .normalHardTtlMs(effectiveHard)
2✔
514
              .normalSoftTtlMs(effectiveSoft)
1✔
515
              .build();
1✔
516
          });
517

518
        if (!skipReport) {
2✔
519
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
520
        }
521
        log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
522
      }
523
      return value;
2✔
524
    });
525
  }
526

527
  /**
528
   * Promotes a non-hot entry to HOT state in L1 if the local TopK detector
529
   * now considers it a hot key. Preserves existing version and TTL metadata.
530
   * {@link KeyState#NORMAL} entries are always eligible; {@link KeyState#COOL}
531
   * entries are only eligible when all Workers are dead (graceful fallback).
532
   *
533
   * @param cacheKey  the key to promote
534
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
535
   * @param val       the extracted value from the cache entry
536
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
537
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
538
   */
539
  private boolean promoteLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
540
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
541
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
542
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
543
        return false;
2✔
544
      }
545
      long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
8!
546
      long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
8!
547
      long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
548
      long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
549

550
      caffeineCache
2✔
551
        .asMap()
10✔
552
        .compute(cacheKey, (k, existing) -> {
2✔
553
          if (existing instanceof CacheEntry entry) {
6!
554
            if (!isPromotableState(entry.getKeyState())) {
5!
555
              return existing;
×
556
            }
557

558
            return entry
2✔
559
              .toBuilder()
2✔
560
              .hardTtlMs(hotHard)
2✔
561
              .softTtlMs(hotSoft)
2✔
562
              .hardExpireAtMs(hotHardExpireAtMs)
2✔
563
              .softExpireAtMs(hotSoftExpireAtMs)
2✔
564
              .keyState(KeyState.HOT)
1✔
565
              .build();
1✔
566
          }
567

568
          return CacheEntry.builder()
×
569
            .value(val)
×
570
            .dataVersion(ce.getDataVersion())
×
571
            .isVersionDegraded(ce.isVersionDegraded())
×
572
            .decisionVersion(ce.getDecisionVersion())
×
573
            .hardTtlMs(hotHard)
×
574
            .hardExpireAtMs(hotHardExpireAtMs)
×
575
            .softTtlMs(hotSoft)
×
576
            .softExpireAtMs(hotSoftExpireAtMs)
×
577
            .keyState(KeyState.HOT)
×
578
            .normalHardTtlMs(ce.getNormalHardTtlMs())
×
579
            .normalSoftTtlMs(ce.getNormalSoftTtlMs())
×
580
            .build();
×
581
        });
582
      return true;
2✔
583
    }
584
    return false;
2✔
585
  }
586

587
  /**
588
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
589
   * so they remove their local copy and re-fetch on next {@link #get}.
590
   * The next local {@link #get} will re-fetch from the reader.
591
   *
592
   * @param cacheKey the key to invalidate
593
   */
594
  public void invalidate(String cacheKey) {
595
    if (invalidCacheKey(cacheKey)) {
3✔
596
      log.debug("invalidate: invalid cacheKey");
3✔
597
      return;
1✔
598
    }
599

600
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
601
      var vr = versionController.nextVersion(cacheKey);
5✔
602
      caffeineCache.invalidate(cacheKey);
4✔
603
      cacheSyncPublisher.ifPresentOrElse(
7✔
604
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
605
        () -> log.debug("invalidate: " + NO_SYNC_PUBLISHER)
4✔
606
      );
607
    });
1✔
608
  }
1✔
609

610
  /**
611
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
612
   * Invalid keys (null or blank) are silently skipped.
613
   *
614
   * @param cacheKeys the keys to invalidate
615
   */
616
  public void invalidateAll(Collection<String> cacheKeys) {
617
    List<String> validKeys = cacheKeys
1✔
618
      .stream()
2✔
619
      .filter(k -> !invalidCacheKey(k))
8✔
620
      .toList();
2✔
621
    if (validKeys.isEmpty()) {
3✔
622
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
623
      return;
1✔
624
    }
625

626
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
627
      caffeineCache.invalidateAll(validKeys);
4✔
628
      cacheSyncPublisher.ifPresentOrElse(
7✔
629
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
630
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
631
      );
632
    });
1✔
633
  }
1✔
634

635
  /**
636
   * Evict keys from the local cache only, without broadcasting to other
637
   * instances and without bumping version numbers.
638
   *
639
   * <p>Useful for emergency local cleanup, testing, or when a module is
640
   * taken offline and only the current node needs to be cleared.
641
   *
642
   * @param cacheKeys the keys to evict locally
643
   */
644
  public void evictLocal(Collection<String> cacheKeys) {
645
    List<String> validKeys = cacheKeys
1✔
646
      .stream()
2✔
647
      .filter(k -> !invalidCacheKey(k))
8✔
648
      .toList();
2✔
649
    if (validKeys.isEmpty()) {
3✔
650
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
651
      return;
1✔
652
    }
653
    caffeineCache.invalidateAll(validKeys);
4✔
654
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
655
  }
1✔
656

657
  /**
658
   * Write-through: execute the writer, then update L1 and broadcast.
659
   * Uses effective hard/soft TTL from configuration.
660
   *
661
   * @param cacheKey the key to write
662
   * @param value    the value to cache
663
   * @param writer   the data-source mutation to execute before caching
664
   * @param <T>      the value type
665
   * @throws HotKeyBlockedException when the key matches a blacklist rule
666
   */
667
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
668
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
669
  }
1✔
670

671
  /**
672
   * Write-through with explicit TTL overrides.
673
   * Pass 0 to use the configured default for that TTL type.
674
   *
675
   * @param cacheKey  the key to write
676
   * @param value     the value to cache
677
   * @param writer    the data-source mutation to execute before caching
678
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
679
   * @param softTtlMs soft TTL override (0 = use configured default)
680
   * @param <T>       the value type
681
   */
682
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
683
    if (invalidCacheKey(cacheKey)) {
3✔
684
      log.debug("putThrough: invalid cacheKey");
3✔
685
      return;
1✔
686
    }
687
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
688
      log.debug("putThrough: blocked by rule: {}", cacheKey);
4✔
689
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
690
    }
691
    TransactionSupport.runAsyncAfterCommit(
10✔
692
      () -> {
693
        try {
694
          writer.run();
2✔
695
        } catch (Exception e) {
×
696
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
697
          return;
×
698
        }
1✔
699
        var vr = versionController.nextVersion(cacheKey);
5✔
700

701
        long effectiveHardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
702
        long effectiveSoftTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
703

704
        caffeineCache
2✔
705
          .asMap()
8✔
706
          .compute(cacheKey, (k, existing) ->
2✔
707
            buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
708
          );
709

710
        cacheSyncPublisher.ifPresentOrElse(
7✔
711
          p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
712
          () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
713
        );
714
      },
1✔
715
      hotKeyExecutor
716
    );
717
  }
1✔
718

719
  /**
720
   * Builds a {@link CacheEntry} for a write-through operation, preserving
721
   * Worker-managed state and version information from the existing entry.
722
   *
723
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
724
   * @param value             the new value to cache
725
   * @param vr                the version result from the version controller
726
   * @param effectiveHardTtl  the effective hard TTL to use
727
   * @param effectiveSoftTtl  the effective soft TTL to use
728
   * @param <T>               the value type
729
   * @return a new {@link CacheEntry} with the supplied value and metadata
730
   */
731
  private <T> CacheEntry buildPutThroughEntry(
732
    Object existing,
733
    T value,
734
    VersionController.VersionResult vr,
735
    long effectiveHardTtl,
736
    long effectiveSoftTtl
737
  ) {
738
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
739
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
740
      return ce;
2✔
741
    }
742

743
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
744

745
    long normalHardTtl = 0;
2✔
746
    long normalSoftTtl = 0;
2✔
747

748
    long decisionVersion = (existing instanceof CacheEntry entry) ? entry.getDecisionVersion() : 0L;
11✔
749
    String decisionNodeId = (existing instanceof CacheEntry entry) ? entry.getDecisionNodeId() : null;
11✔
750
    long decisionEpoch = (existing instanceof CacheEntry entry) ? entry.getDecisionEpoch() : 0L;
11✔
751

752
    if (existing instanceof CacheEntry) {
3✔
753
      normalHardTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalHardTtlMs() : effectiveHardTtl;
7!
754
    }
755
    if (existing instanceof CacheEntry) {
3✔
756
      normalSoftTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalSoftTtlMs() : effectiveSoftTtl;
7!
757
    }
758

759
    return CacheEntry.builder()
2✔
760
      .value(value != null ? value : NullValue.INSTANCE)
7✔
761
      .dataVersion(vr.dataVersion())
3✔
762
      .isVersionDegraded(vr.degraded())
3✔
763
      .decisionVersion(decisionVersion)
2✔
764
      .decisionNodeId(decisionNodeId)
2✔
765
      .decisionEpoch(decisionEpoch)
1✔
766
      .hardTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotHardTtlMs() : effectiveHardTtl)
9✔
767
      .hardExpireAtMs(
1✔
768
        state == KeyState.HOT
3✔
769
          ? expireManager.computeHotHardExpireAt()
4✔
770
          : expireManager.computeHardExpireAt(effectiveHardTtl)
4✔
771
      )
772
      .softTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotSoftTtlMs() : effectiveSoftTtl)
9✔
773
      .softExpireAtMs(
2✔
774
        state == KeyState.HOT
3✔
775
          ? expireManager.computeHotSoftExpireAt()
4✔
776
          : expireManager.computeSoftExpireAt(effectiveSoftTtl)
4✔
777
      )
778
      .keyState(state)
2✔
779
      .normalHardTtlMs(normalHardTtl)
2✔
780
      .normalSoftTtlMs(normalSoftTtl)
1✔
781
      .build();
1✔
782
  }
783

784
  /**
785
   * Write a value directly into the local L1 cache without version bump,
786
   * without broadcast, without hot-key detection, and without reporting.
787
   * <p>
788
   * Existing entry metadata is preserved.  If no entry exists, a fresh
789
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
790
   * <p>
791
   * Pass {@code 0} for either TTL to use the configured default.
792
   *
793
   * @param cacheKey  the key to store
794
   * @param value     the value to cache
795
   * @param hardTtlMs hard TTL override (0 = use configured default)
796
   * @param softTtlMs soft TTL override (0 = use configured default)
797
   */
798
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
799
    if (invalidCacheKey(cacheKey)) {
3✔
800
      log.debug("putLocal: invalid cacheKey");
3✔
801
      return;
1✔
802
    }
803
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
804
      log.debug("putLocal: blocked by rule: {}", cacheKey);
4✔
805
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
806
    }
807

808
    long hardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
809
    long softTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
810

811
    caffeineCache
2✔
812
      .asMap()
7✔
813
      .compute(cacheKey, (k, existing) -> {
2✔
814
        if (existing instanceof CacheEntry ce) {
6✔
815
          return ce
2✔
816
            .toBuilder()
2✔
817
            .value(value)
2✔
818
            .hardTtlMs(hardTtl)
2✔
819
            .softTtlMs(softTtl)
4✔
820
            .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
5✔
821
            .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
2✔
822
            .build();
1✔
823
        }
824
        return CacheEntry.builder()
3✔
825
          .value(value)
2✔
826
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
827
          .isVersionDegraded(false)
2✔
828
          .decisionVersion(0L)
2✔
829
          .hardTtlMs(hardTtl)
4✔
830
          .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
3✔
831
          .softTtlMs(softTtl)
4✔
832
          .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
3✔
833
          .keyState(KeyState.NORMAL)
2✔
834
          .normalHardTtlMs(hardTtl)
2✔
835
          .normalSoftTtlMs(softTtl)
1✔
836
          .build();
1✔
837
      });
838
  }
1✔
839

840
  /**
841
   * Execute a mutation, then invalidate L1 and broadcast.
842
   * Next {@link #get} will re-fetch from the reader.
843
   *
844
   * @param cacheKey the key to invalidate after mutation
845
   * @param mutation the mutation to execute
846
   */
847
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
848
    if (invalidCacheKey(cacheKey)) {
3✔
849
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
850
      return;
1✔
851
    }
852
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
853
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
854
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
855
    }
856
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
857
      try {
858
        mutation.run();
2✔
859
      } catch (Exception e) {
1✔
860
        log.error("putBeforeInvalidate failed, skip local invalidate and broadcast: {}", cacheKey, e);
5✔
861
        return;
1✔
862
      }
1✔
863
      var vr = versionController.nextVersion(cacheKey);
5✔
864
      caffeineCache.invalidate(cacheKey);
4✔
865

866
      cacheSyncPublisher.ifPresentOrElse(
7✔
867
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
868
        () -> log.debug("putBeforeInvalidate: {}", NO_SYNC_PUBLISHER)
5✔
869
      );
870
    });
1✔
871
  }
1✔
872

873
  /**
874
   * Add a key pattern to the blacklist.
875
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
876
   *
877
   * @param cacheKey the key pattern to blacklist
878
   */
879
  public void addBlacklist(String cacheKey) {
880
    if (invalidCacheKey(cacheKey)) {
3✔
881
      log.debug("blacklist: invalid cacheKey");
3✔
882
      return;
1✔
883
    }
884
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK)));
11✔
885
  }
1✔
886

887
  /**
888
   * Add a key pattern to the whitelist.
889
   * Matching keys are allowed but bypass app-to-Worker reporting.
890
   *
891
   * @param cacheKey the key pattern to whitelist
892
   */
893
  public void addWhitelist(String cacheKey) {
894
    if (invalidCacheKey(cacheKey)) {
3✔
895
      log.debug("whitelist: invalid cacheKey");
3✔
896
      return;
1✔
897
    }
898
    TransactionSupport.runNowOrAfterCommit(() ->
4✔
899
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT))
7✔
900
    );
901
  }
1✔
902

903
  /**
904
   * Remove a key pattern from the blacklist.
905
   *
906
   * @param cacheKey the key pattern to remove from the blacklist
907
   */
908
  public void unBlacklist(String cacheKey) {
909
    if (invalidCacheKey(cacheKey)) {
3✔
910
      log.debug("unblacklist: invalid cacheKey");
3✔
911
      return;
1✔
912
    }
913
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK));
11✔
914
  }
1✔
915

916
  /**
917
   * Remove a key pattern from the whitelist.
918
   *
919
   * @param cacheKey the key pattern to remove from the whitelist
920
   */
921
  public void unWhitelist(String cacheKey) {
922
    if (invalidCacheKey(cacheKey)) {
3✔
923
      log.debug("unwhitelist: invalid cacheKey");
3✔
924
      return;
1✔
925
    }
926
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT));
11✔
927
  }
1✔
928

929
  /**
930
   * Evaluate all rules against the given key and return the first matching action.
931
   *
932
   * @param cacheKey the key to evaluate
933
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
934
   */
935
  public RuleAction evaluateRule(String cacheKey) {
936
    return ruleMatcher.evaluateRule(cacheKey);
5✔
937
  }
938

939
  /**
940
   * Return a snapshot of all current rules in evaluation order.
941
   *
942
   * @return list of rules (immutable snapshot)
943
   */
944
  public List<Rule> getAllRules() {
945
    return ruleMatcher.getAllRules();
4✔
946
  }
947

948
  /**
949
   * Remove all blacklist and whitelist rules.
950
   */
951
  public void clearAllRules() {
952
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
953
  }
1✔
954

955
  /**
956
   * Broadcast all local rules to peer instances via the sync exchange.
957
   * Useful for initial synchronization when a new instance joins the cluster.
958
   */
959
  public void broadcastAllLocalRulesManually() {
960
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
961
  }
1✔
962

963
  /**
964
   * Estimated number of entries currently in the L1 cache.
965
   *
966
   * @return best-effort estimate of the current entry count
967
   */
968
  public long estimatedSize() {
969
    return caffeineCache.estimatedSize();
4✔
970
  }
971

972
  /**
973
   * Return a snapshot of basic L1 cache statistics.
974
   * <p>
975
   * Hit/miss/eviction counters are populated only when Caffeine's
976
   * {@code recordStats()} is enabled.  {@code estimatedSize} is always
977
   * available.
978
   *
979
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
980
   *         if stats recording is not enabled
981
   */
982
  public HotKeyCacheStats stats() {
983
    CacheStats cs = caffeineCache.stats();
4✔
984
    return new HotKeyCacheStats(
4✔
985
      cs.hitCount(),
2✔
986
      cs.missCount(),
2✔
987
      cs.hitRate(),
2✔
988
      cs.evictionCount(),
3✔
989
      caffeineCache.estimatedSize()
2✔
990
    );
991
  }
992

993
  /**
994
   * Check whether the given key is blacklisted.
995
   *
996
   * @param cacheKey the key to check
997
   * @return {@code true} if a blacklist rule matches the key
998
   */
999

1000
  public boolean isBlacklisted(String cacheKey) {
1001
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1002
  }
1003

1004
  /**
1005
   * Check whether the given key is whitelisted (skips Worker reporting).
1006
   *
1007
   * @param cacheKey the key to check
1008
   * @return {@code true} if a whitelist rule matches the key
1009
   */
1010
  public boolean isWhitelisted(String cacheKey) {
1011
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1012
  }
1013

1014
  /**
1015
   * Invalidate all entries from the L1 cache without broadcasting.
1016
   * <p>
1017
   * This is an emergency flush — all cached values are removed immediately.
1018
   * No cross-instance sync messages are sent.
1019
   */
1020
  public void invalidateAllLocal() {
1021
    caffeineCache.invalidateAll();
3✔
1022
  }
1✔
1023

1024
  /**
1025
   * Return the underlying Caffeine cache for direct access.
1026
   *
1027
   * <p>This provides access to Caffeine-specific operations such as
1028
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
1029
   * caution — bypassing the HotKey orchestration layer (version tracking,
1030
   * broadcast, expiry management) can lead to inconsistent state.
1031
   *
1032
   * @return the raw Caffeine {@link Cache} instance
1033
   */
1034
  public Cache<String, Object> getLocalCache() {
1035
    return caffeineCache;
3✔
1036
  }
1037

1038
  /**
1039
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1040
   * <p>
1041
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1042
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1043
   * transparently returned as {@code null} to callers.
1044
   *
1045
   * @param value the raw value from a {@link CacheEntry}
1046
   * @return {@code null} if the sentinel, otherwise the original value
1047
   */
1048
  @Nullable
1049
  private static Object unwrapNull(@Nullable Object value) {
1050
    return value == NullValue.INSTANCE ? null : value;
7✔
1051
  }
1052
}
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