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

Hyshmily / hotkey / 28705436176

04 Jul 2026 11:56AM UTC coverage: 90.132% (-0.3%) from 90.399%
28705436176

push

github

Hyshmily
Merge remote-tracking branch 'hotkey/master'

1261 of 1453 branches covered (86.79%)

Branch coverage included in aggregate %.

3589 of 3928 relevant lines covered (91.37%)

4.18 hits per line

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

84.08
/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.cachesupport.CacheKeysPolicy.invalidCacheKey;
19
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.VERSION_DEFAULT;
20

21
import com.github.benmanes.caffeine.cache.Cache;
22
import com.github.benmanes.caffeine.cache.stats.CacheStats;
23
import io.github.hyshmily.hotkey.Internal;
24
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
25
import io.github.hyshmily.hotkey.cache.annotationsupporter.NullValue;
26
import io.github.hyshmily.hotkey.cache.cachesupport.CacheExpireManager;
27
import io.github.hyshmily.hotkey.cache.cachesupport.SingleFlight;
28
import io.github.hyshmily.hotkey.cache.cachesupport.TransactionSupport;
29
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
30
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
31
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
32
import io.github.hyshmily.hotkey.model.CacheEntry;
33
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
34
import io.github.hyshmily.hotkey.model.KeyState;
35
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
36
import io.github.hyshmily.hotkey.rule.Rule;
37
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
38
import io.github.hyshmily.hotkey.rule.RuleMatcher;
39
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
40
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
41
import io.github.hyshmily.hotkey.util.TimeSource;
42
import io.github.hyshmily.hotkey.util.version.VersionController;
43
import io.github.hyshmily.hotkey.util.version.VersionGuard;
44
import jakarta.annotation.Nullable;
45
import java.util.Collection;
46
import java.util.List;
47
import java.util.Optional;
48
import java.util.concurrent.Executor;
49
import java.util.concurrent.RejectedExecutionException;
50
import java.util.concurrent.TimeUnit;
51
import java.util.concurrent.atomic.AtomicBoolean;
52
import java.util.function.Supplier;
53
import lombok.RequiredArgsConstructor;
54
import lombok.extern.slf4j.Slf4j;
55

56
/**
57
 * Core orchestration class for hot-key caching.
58
 * <p>
59
 * Manages L1 (Caffeine) operations with hot-key awareness, dual version tracking
60
 * ({@code dataVersion} for data mutation ordering, {@code decisionVersion} for
61
 * Worker HOT/COOL decision ordering),
62
 * SingleFlight deduplication, soft-expire (stale-while-revalidate), and
63
 * cross-instance synchronization via {@link CacheSyncPublisher}.
64
 * All write operations are transaction-aware via {@link TransactionSupport}.
65
 */
66
@RequiredArgsConstructor
67
@Slf4j
4✔
68
@Internal
69
public class HotKeyCache {
70

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

90
  /** HotKey configuration properties (TTL overrides, null-value TTL). */
91
  private final HotKeyProperties hotKeyProperties;
92

93
  /** Cached view of Worker cluster health, used for COOL promotion decisions. */
94
  private final ClusterHealthView healthView;
95

96
  /** Log message constant when no sync publisher is available. */
97
  private static final String NO_SYNC_PUBLISHER = HotKeyConstants.NO_SYNC_PUBLISHER;
98

99
  /**
100
   * Check whether an existing cache entry is managed by the Worker (HOT or COOL).
101
   * Worker-managed entries preserve their original normal TTLs through writes.
102
   *
103
   * @param existing the existing cache entry (maybe {@code null} or a raw value)
104
   * @return {@code true} if the entry is a {@link CacheEntry} with state HOT or COOL
105
   */
106
  private static boolean isWorkerManagedEntry(Object existing) {
107
    return (
1✔
108
      existing instanceof CacheEntry entry &&
7✔
109
      (entry.getKeyState() == KeyState.HOT || entry.getKeyState() == KeyState.COOL)
10✔
110
    );
111
  }
112

113
  /**
114
   * Check whether a key is currently tracked as a local hot key in L1.
115
   *
116
   * @param cacheKey the key to inspect
117
   * @return {@code true} if the key exists in L1 with {@link KeyState#HOT}
118
   */
119
  public boolean isLocalHotKey(String cacheKey) {
120
    if (invalidCacheKey(cacheKey)) {
3✔
121
      log.debug("isLocalHotKey: invalid cacheKey");
3✔
122
      return false;
2✔
123
    }
124
    Object entry = caffeineCache.getIfPresent(cacheKey);
5✔
125
    if (entry instanceof CacheEntry ce) {
6✔
126
      if (expireManager.isLogicallyExpired(ce)) {
5✔
127
        return false;
2✔
128
      }
129
      // Also check HeavyKeeper for NORMAL entries promoted by Worker broadcast detection
130
      return KeyState.HOT == ce.getKeyState() || hotKeyDetector.contains(cacheKey);
13!
131
    }
132
    // Fallback to HeavyKeeper for keys that are hot in the detection engine but not yet
133
    // promoted to L1 with a CacheEntry wrapper (e.g., during the first detection window).
134
    return hotKeyDetector.contains(cacheKey);
5✔
135
  }
136

137
  /**
138
   * Whether the given {@link KeyState} is eligible for local promotion to HOT.
139
   * <p>
140
   * {@link KeyState#NORMAL} entries are always eligible: when the local TopK
141
   * detects them as hot, they get promoted to HOT with longer TTLs.
142
   * <p>
143
   * {@link KeyState#COOL} entries are only eligible when no Worker shard is
144
   * alive ( returns {@code false}).
145
   * This provides graceful degradation — when the Worker cluster is unavailable,
146
   * the local TopK drives TTL decisions instead of preserving stale Worker verdicts.
147
   * Once a Worker comes back online and broadcasts a new decision, it overrides
148
   * the local promotion via {@code decisionVersion} comparison.
149
   * <p>
150
   * {@link KeyState#HOT} entries are never eligible — they already have the
151
   * longest TTLs.
152
   *
153
   * @param state the current key state of the cache entry
154
   * @return {@code true} if the entry may be promoted by local TopK
155
   */
156
  private boolean isPromotableState(KeyState state) {
157
    return state == KeyState.NORMAL || (state == KeyState.COOL && !healthView.isClusterHealthy());
14✔
158
  }
159

160
  /**
161
   * Look up a cached value without loading or triggering hot-key detection.
162
   * Unlike {@link #get}, this method never invokes the reader or SingleFlight.
163
   *
164
   * @param cacheKey the key to inspect
165
   * @return an {@link Optional} containing the raw value if present
166
   */
167
  @SuppressWarnings("unchecked")
168
  public <T> Optional<T> peek(String cacheKey) {
169
    if (invalidCacheKey(cacheKey)) {
3✔
170
      log.debug("peek: invalid cacheKey");
3✔
171
      return Optional.empty();
2✔
172
    }
173

174
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
175
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
176
    }
177

178
    try {
179
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey)).map(raw ->
8✔
180
        raw instanceof CacheEntry vv ? (T) unwrapNull(vv.getValue()) : (T) raw
12✔
181
      );
182
    } catch (HotKeyBlockedException e) {
×
183
      throw e;
×
184
    } catch (RuntimeException e) {
×
185
      log.error("HotKeyCache.peek internal error for key={}, returning empty", cacheKey, e);
×
186
      return Optional.empty();
×
187
    }
188
  }
189

190
  /**
191
   * Get a value from L1 or load it via the reader.
192
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
193
   *
194
   * @param cacheKey the key to retrieve
195
   * @param reader   the value supplier for cache misses
196
   * @param <T>      the value type
197
   * @return an {@link Optional} containing the cached or loaded value
198
   * @throws HotKeyBlockedException when the key matches a blacklist rule
199
   */
200
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
201
    return get(cacheKey, reader, 0L, 0L);
7✔
202
  }
203

204
  /**
205
   * Get with explicit TTL overrides.
206
   * Pass 0 to use the configured default for that TTL type.
207
   *
208
   * @param cacheKey  the key to retrieve
209
   * @param reader    the value supplier for cache misses
210
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
211
   * @param softTtlMs soft TTL override (0 = use configured default)
212
   * @param <T>       the value type
213
   * @return an {@link Optional} containing the cached or loaded value
214
   * @throws HotKeyBlockedException when the key matches a blacklist rule
215
   */
216
  @SuppressWarnings("unchecked")
217
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
218
    // Graceful degradation: invalid cache key is a caller-side issue, not a HotKey internal
219
    // failure. Return empty rather than throwing to keep callers operational.
220
    if (invalidCacheKey(cacheKey)) {
3✔
221
      log.debug("get: invalid cacheKey");
3✔
222
      return Optional.empty();
2✔
223
    }
224

225
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
226
    if (action == RuleAction.BLOCK) {
3✔
227
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
228
    }
229
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
230

231
    try {
232
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
233
        .flatMap(raw -> {
8✔
234
          if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, raw)) {
6✔
235
            return Optional.empty();
2✔
236
          }
237

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

240
          return processHitAndValidate(cacheKey, raw, val, hardTtlMs, softTtlMs, skipReport);
9✔
241
        })
242
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
243
    } catch (HotKeyBlockedException e) {
×
244
      throw e;
×
245
    } catch (RuntimeException e) {
×
246
      log.error("HotKeyCache.get internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
247
      return Optional.empty();
×
248
    }
249
  }
250

251
  /**
252
   * Get with soft-expire (stale-while-revalidate). Returns cached value immediately
253
   * even if soft TTL expired, while triggering async refresh in background.
254
   * Only HOT and COOL entries are subject to soft expire.
255
   *
256
   * @param cacheKey the key to retrieve
257
   * @param reader   the value supplier for cache misses / refreshes
258
   * @param <T>      the value type
259
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
260
   * @throws HotKeyBlockedException when the key matches a blacklist rule
261
   */
262
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader) {
263
    return getWithSoftExpire(cacheKey, reader, 0L, 0L);
7✔
264
  }
265

266
  /**
267
   * Get with soft-expire and explicit soft TTL override.
268
   *
269
   * @param cacheKey  the key to retrieve
270
   * @param reader    the value supplier for cache misses / refreshes
271
   * @param softTtlMs soft TTL override (0 = use configured default)
272
   * @param <T>       the value type
273
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
274
   * @throws HotKeyBlockedException when the key matches a blacklist rule
275
   */
276
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
277
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
278
  }
279

280
  /**
281
   * Get with soft-expire and explicit hard/soft TTL overrides.
282
   *
283
   * @param cacheKey  the key to retrieve
284
   * @param reader    the value supplier for cache misses / refreshes
285
   * @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})
286
   * @param softTtlMs soft TTL override (0 = use configured default)
287
   * @param <T>       the value type
288
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
289
   * @throws HotKeyBlockedException when the key matches a blacklist rule
290
   */
291
  @SuppressWarnings("all")
292
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
293
    if (invalidCacheKey(cacheKey)) {
3✔
294
      log.debug("getWithSoftExpire: invalid cacheKey");
3✔
295
      return Optional.empty();
2✔
296
    }
297

298
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
299
    if (action == RuleAction.BLOCK) {
3✔
300
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
301
    }
302
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
303

304
    if (!expireManager.isSoftExpireEnabled()) {
4✔
305
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
306
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
307
    }
308
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
309
    try {
310
      return Optional.ofNullable(raw)
11✔
311
        .flatMap(v -> {
8✔
312
          if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, v)) {
6✔
313
            return Optional.empty();
2✔
314
          }
315

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

318
          if (isWorkerManagedEntry(v)) {
3✔
319
            CacheEntry ce = (CacheEntry) v;
3✔
320
            if (expireManager.isSoftExpired(ce)) {
5✔
321
              long effectiveSoft =
322
                softTtlMs > 0
4✔
323
                  ? softTtlMs
2✔
324
                  : (KeyState.HOT == ce.getKeyState()
4!
325
                      ? expireManager.getEffectiveHotSoftTtlMs()
×
326
                      : ce.getNormalSoftTtlMs() > 0
5✔
327
                        ? ce.getNormalSoftTtlMs()
3✔
328
                        : expireManager.getEffectiveSoftTtlMs());
4✔
329

330
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
331
            }
332
          }
333

334
          return processHitAndValidate(cacheKey, raw, cached, hardTtlMs, softTtlMs, skipReport);
9✔
335
        })
336
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
337
    } catch (HotKeyBlockedException e) {
×
338
      throw e;
×
339
    } catch (RuntimeException e) {
×
340
      log.error(
×
341
        "HotKeyCache.getWithSoftExpire internal error for key={}, returning empty to keep caller operational",
342
        cacheKey,
343
        e
344
      );
345
      return Optional.empty();
×
346
    }
347
  }
348

349
  /**
350
   * Process a cache hit: trigger local hot-key promotion/renewal and report
351
   * to Worker, then re-check logical expiry (TOCTOU guard) after side effects.
352
   * <p>Extracted from {@link #get} and {@link #getWithSoftExpire} to eliminate
353
   * code duplication.
354
   *
355
   * @param cacheKey  the cache key
356
   * @param raw       the raw value from Caffeine (may be {@link CacheEntry} or bare)
357
   * @param cached    the unwrapped cached value
358
   * @param hardTtlMs hard TTL override
359
   * @param softTtlMs soft TTL override
360
   * @param skipReport if {@code true}, skip app-to-Worker reporting
361
   * @param <T>       the value type
362
   * @return an {@link Optional} containing the cached value, or empty if logically expired
363
   */
364
  private <T> Optional<T> processHitAndValidate(
365
    String cacheKey,
366
    Object raw,
367
    T cached,
368
    long hardTtlMs,
369
    long softTtlMs,
370
    boolean skipReport
371
  ) {
372
    boolean wasProcessed = processLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
373
    if (!skipReport) {
2✔
374
      hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
375
    }
376

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

385
    return Optional.ofNullable(cached);
3✔
386
  }
387

388
  /**
389
   * Load via SingleFlight, detect hot key, cache with HOT or NORMAL TTL, and return value.
390
   *
391
   * @param cacheKey   the key to load
392
   * @param reader     the value supplier
393
   * @param hardTtlMs  hard TTL override (0 = use configured default)
394
   * @param softTtlMs  soft TTL override (0 = use configured default)
395
   * @param skipReport if {@code true}, skip reporting to Worker
396
   */
397
  @SuppressWarnings("unchecked")
398
  private <T> Optional<T> loadAndCache(
399
    String cacheKey,
400
    Supplier<T> reader,
401
    long hardTtlMs,
402
    long softTtlMs,
403
    boolean skipReport
404
  ) {
405
    // Secondary check: rule may have been added after the entry check in get/getWithSoftExpire
406
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
407
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
408
    }
409
    Optional<T> result = singleFlight.load(cacheKey, reader);
6✔
410

411
    if (result.isEmpty()) {
3!
412
      if (singleFlight.isBreakerOpen()) {
×
413
        Object stale = caffeineCache.getIfPresent(cacheKey);
×
414

415
        if (stale != null) {
×
416
          T val = stale instanceof CacheEntry ce ? (T) unwrapNull(ce.getValue()) : (T) stale;
×
417
          log.debug("CB open, returning stale entry for key={}", cacheKey);
×
418
          return Optional.ofNullable(val);
×
419
        }
420
      }
421
      long nullTtlMs = TimeUnit.SECONDS.toMillis(hotKeyProperties.getNullValueTtlSeconds());
×
422
      long nullExpireAtMs = expireManager.computeNullExpireAt(nullTtlMs);
×
423

424
      caffeineCache.put(
×
425
        cacheKey,
426
        CacheEntry.builder()
×
427
          .value(NullValue.INSTANCE)
×
428
          .dataVersion(VERSION_DEFAULT)
×
429
          .isVersionDegraded(false)
×
430
          .decisionVersion(0L)
×
431
          .hardTtlMs(nullTtlMs)
×
432
          .hardExpireAtMs(nullExpireAtMs)
×
433
          .softTtlMs(0)
×
434
          .softExpireAtMs(0)
×
435
          .keyState(KeyState.NORMAL)
×
436
          .build()
×
437
      );
438
      return Optional.empty();
×
439
    }
440
    return result.map(value -> {
9✔
441
      if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
442
        throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
443
      }
444

445
      long effectiveHard = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
446
      long effectiveSoft = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
447

448
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
449
      if (hotKeyDetector.contains(cacheKey)) {
5✔
450
        long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
451
        long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
452

453
        caffeineCache
2✔
454
          .asMap()
9✔
455
          .compute(cacheKey, (k, existing) -> {
2✔
456
            if (isWorkerManagedEntry(existing)) {
3✔
457
              return existing;
2✔
458
            }
459
            return buildEntry(value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
460
          });
461

462
        if (!skipReport) {
2✔
463
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
464
        }
465
        log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
466
      } else {
1✔
467
        caffeineCache
2✔
468
          .asMap()
7✔
469
          .compute(cacheKey, (k, existing) -> {
2✔
470
            if (isWorkerManagedEntry(existing)) {
3✔
471
              return existing;
2✔
472
            }
473
            return buildEntry(value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
474
          });
475

476
        if (!skipReport) {
2✔
477
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
478
        }
479
        log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
480
      }
481
      return value;
2✔
482
    });
483
  }
484

485
  private <T> CacheEntry buildEntry(
486
    T value,
487
    long hardTtlMs,
488
    long softTtlMs,
489
    KeyState state,
490
    long normalHardTtlMs,
491
    long normalSoftTtlMs
492
  ) {
493
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
494
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
495

496
    return expireManager.createBuilder(
15✔
497
      value,
498
      VERSION_DEFAULT,
499
      false,
500
      VERSION_DEFAULT,
501
      hardTtlMs,
502
      softTtlMs,
503
      hardTtlExpireAtMs,
504
      softTtlExpireAtMs,
505
      normalHardTtlMs,
506
      normalSoftTtlMs,
507
      state
508
    );
509
  }
510

511
  /**
512
   * Process a cache hit for local hot-key management: if the entry is already
513
   * HOT and more than half its TTL has elapsed, extend its expiry window;
514
   * otherwise promote eligible non-hot entries (NORMAL or COOL-when-all-dead)
515
   * to HOT if the local TopK now considers them hot.
516
   * <p>
517
   * HOT entries that are still within their first half are left untouched —
518
   * no need to re-insert the same state.
519
   *
520
   * @param cacheKey  the key to promote
521
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
522
   * @param val       the extracted value from the cache entry
523
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
524
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
525
   * @return {@code true} if a local promotion or expiry extension occurred
526
   */
527
  private boolean processLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
528
    if (raw instanceof CacheEntry ce && ce.getKeyState() == KeyState.HOT && ce.getHardExpireAtMs() != Long.MAX_VALUE) {
15✔
529
      long remainingTtl = ce.getHardExpireAtMs() - TimeSource.currentTimeMillis();
5✔
530
      long totalTtl = ce.getHardTtlMs();
3✔
531

532
      if (totalTtl > 0 && remainingTtl < totalTtl / 2) {
10!
533
        expireManager.extendExpiry(cacheKey, hardTtlMs, softTtlMs);
6✔
534
        return true;
2✔
535
      }
536
      return false;
2✔
537
    }
538

539
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
540
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
541
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
542
        return false;
2✔
543
      }
544
      long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
545
      long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
546

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

555
            return expireManager.applyTtl(entry, hotHard, hotSoft).toBuilder().keyState(KeyState.HOT).build();
11✔
556
          }
557

558
          return expireManager.createBuilder(
×
559
            val,
560
            ce.getDataVersion(),
×
561
            ce.isVersionDegraded(),
×
562
            ce.getDecisionVersion(),
×
563
            hotHard,
564
            hotSoft,
565
            ce.getNormalHardTtlMs(),
×
566
            ce.getNormalSoftTtlMs(),
×
567
            KeyState.HOT
568
          );
569
        });
570
      return true;
2✔
571
    }
572
    return false;
2✔
573
  }
574

575
  /**
576
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
577
   * so they remove their local copy and re-fetch on next {@link #get}.
578
   * The next local {@link #get} will re-fetch from the reader.
579
   *
580
   * @param cacheKey the key to invalidate
581
   */
582
  public void invalidate(String cacheKey) {
583
    if (invalidCacheKey(cacheKey)) {
3✔
584
      log.debug("invalidate: invalid cacheKey");
3✔
585
      return;
1✔
586
    }
587

588
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
589
      // Local L1 must be dropped synchronously so subsequent reads see the
590
      // invalidation immediately, even if the executor is saturated.
591
      caffeineCache.invalidate(cacheKey);
4✔
592

593
      // Version bump (Redis INCR) + broadcast happen asynchronously. If
594
      // hotKeyExecutor rejects or Redis fails, VersionController.nextVersion
595
      // already falls back to a degraded local counter (ADR-0009), so the
596
      // broadcast still propagates with isVersionDegraded=true and peers
597
      // honor it via the 4-case VersionGuard comparison.
598
      try {
599
        hotKeyExecutor.execute(() -> {
6✔
600
          try {
601
            var vr = versionController.nextVersion(cacheKey);
5✔
602
            cacheSyncPublisher.ifPresentOrElse(
7✔
603
              p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
604
              () -> log.debug("invalidate async: " + NO_SYNC_PUBLISHER)
4✔
605
            );
606
          } catch (Exception ex) {
×
607
            log.error("invalidate async broadcast failed for key={}", cacheKey, ex);
×
608
          }
1✔
609
        });
1✔
610
      } catch (RejectedExecutionException ree) {
×
611
        log.warn(
×
612
          "invalidate executor rejected for key={}, peer invalidation deferred to next cycle: {}",
613
          cacheKey,
614
          ree.getMessage()
×
615
        );
616
      }
1✔
617
    });
1✔
618
  }
1✔
619

620
  /**
621
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
622
   * Invalid keys (null or blank) are silently skipped.
623
   *
624
   * @param cacheKeys the keys to invalidate
625
   */
626
  public void invalidateAll(Collection<String> cacheKeys) {
627
    List<String> validKeys = cacheKeys
1✔
628
      .stream()
2✔
629
      .filter(k -> !invalidCacheKey(k))
8✔
630
      .toList();
2✔
631
    if (validKeys.isEmpty()) {
3✔
632
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
633
      return;
1✔
634
    }
635

636
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
637
      caffeineCache.invalidateAll(validKeys);
4✔
638
      cacheSyncPublisher.ifPresentOrElse(
7✔
639
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
640
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
641
      );
642
    });
1✔
643
  }
1✔
644

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

667
  /**
668
   * Write-through: execute the writer, then update L1 and broadcast.
669
   * Uses effective hard/soft TTL from configuration.
670
   *
671
   * @param cacheKey the key to write
672
   * @param value    the value to cache
673
   * @param writer   the data-source mutation to execute before caching
674
   * @param <T>      the value type
675
   * @throws HotKeyBlockedException when the key matches a blacklist rule
676
   */
677
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
678
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
679
  }
1✔
680

681
  /**
682
   * Write-through with explicit TTL overrides.
683
   * Pass 0 to use the configured default for that TTL type.
684
   *
685
   * @param cacheKey  the key to write
686
   * @param value     the value to cache
687
   * @param writer    the data-source mutation to execute before caching
688
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
689
   * @param softTtlMs soft TTL override (0 = use configured default)
690
   * @param <T>       the value type
691
   */
692
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
693
    if (invalidCacheKey(cacheKey)) {
3✔
694
      log.debug("putThrough: invalid cacheKey");
3✔
695
      return;
1✔
696
    }
697
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
698
      log.debug("putThrough: blocked by rule: {}", cacheKey);
4✔
699
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
700
    }
701

702
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
703

704
    TransactionSupport.runAsyncAfterCommit(
11✔
705
      () -> {
706
        try {
707
          writer.run();
2✔
708
          writerOk.compareAndSet(false, true);
5✔
709
        } catch (Exception e) {
×
710
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
711
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
712
          return;
×
713
        }
1✔
714

715
        long effectiveHardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
716
        long effectiveSoftTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
717

718
        try {
719
          var vr = versionController.nextVersion(cacheKey);
5✔
720

721
          caffeineCache
2✔
722
            .asMap()
8✔
723
            .compute(cacheKey, (k, existing) ->
2✔
724
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
725
            );
726

727
          cacheSyncPublisher.ifPresentOrElse(
7✔
728
            p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
729
            () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
730
          );
731
        } catch (RejectedExecutionException ree) {
×
732
          // hotKeyExecutor saturated mid-body (nextVersion/Redis did run, but
733
          // we cannot distinguish here — VersionController.nextVersion already
734
          // has its own Redis-degraded fallback path). Cache and broadcast are
735
          // skipped; the next read or the next periodic Worker cycle will
736
          // reconcile. Logged at ERROR so operators can detect silent drops.
737
          log.error(
×
738
            "putThrough executor rejected mid-flight for key={} (local cache NOT updated, broadcast NOT sent)",
739
            cacheKey,
740
            ree
741
          );
742
          throw ree; // CompletableFuture.exceptionally downstream decides policy
×
743
        } catch (Exception e) {
×
744
          // Redis-relayed exception or unexpected error. The local L1 is still
745
          // updated using a degraded version so the cache remains coherent with
746
          // the mutation that already succeeded on the writer.
747
          log.error("putThrough cache/broadcast failed for key={} (applying degraded local update)", cacheKey, e);
×
748
          var vr = versionController.fallbackVersion();
×
749

750
          caffeineCache
×
751
            .asMap()
×
752
            .compute(cacheKey, (k, existing) ->
×
753
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
754
            );
755
          cacheSyncPublisher.ifPresent(p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()));
×
756
        }
1✔
757
      },
1✔
758
      hotKeyExecutor
759
    );
760
  }
1✔
761

762
  /**
763
   * Builds a {@link CacheEntry} for a write-through operation, preserving
764
   * Worker-managed state and version information from the existing entry.
765
   *
766
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
767
   * @param value             the new value to cache
768
   * @param vr                the version result from the version controller
769
   * @param effectiveHardTtl  the effective hard TTL to use
770
   * @param effectiveSoftTtl  the effective soft TTL to use
771
   * @param <T>               the value type
772
   * @return a new {@link CacheEntry} with the supplied value and metadata
773
   */
774
  @SuppressWarnings("all")
775
  private <T> CacheEntry buildPutThroughEntry(
776
    Object existing,
777
    T value,
778
    VersionController.VersionResult vr,
779
    long effectiveHardTtl,
780
    long effectiveSoftTtl
781
  ) {
782
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
783
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
784
      return ce;
2✔
785
    }
786

787
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
788

789
    long decisionVersion = 0L;
2✔
790
    String decisionNodeId = null;
2✔
791
    long decisionEpoch = 0L;
2✔
792
    long normalHardTtl = effectiveHardTtl;
2✔
793
    long normalSoftTtl = effectiveSoftTtl;
2✔
794

795
    if (existing instanceof CacheEntry entry) {
6✔
796
      decisionVersion = entry.getDecisionVersion();
3✔
797
      decisionNodeId = entry.getDecisionNodeId();
3✔
798
      decisionEpoch = entry.getDecisionEpoch();
3✔
799
      if (isWorkerManaged) {
2!
800
        normalHardTtl = entry.getNormalHardTtlMs();
3✔
801
        normalSoftTtl = entry.getNormalSoftTtlMs();
3✔
802
      }
803
    }
804

805
    long hardTtl = effectiveHardTtl;
2✔
806
    long softTtl = effectiveSoftTtl;
2✔
807
    if (state == KeyState.HOT) {
3✔
808
      hardTtl = expireManager.resolveEffectiveHotHard(hardTtl);
5✔
809
      softTtl = expireManager.resolveEffectiveHotSoft(softTtl);
5✔
810
    }
811

812
    return expireManager.createBuilder(
4✔
813
      value != null ? value : NullValue.INSTANCE,
6✔
814
      vr.dataVersion(),
2✔
815
      vr.degraded(),
9✔
816
      decisionVersion,
817
      decisionNodeId,
818
      decisionEpoch,
819
      hardTtl,
820
      softTtl,
821
      normalHardTtl,
822
      normalSoftTtl,
823
      state
824
    );
825
  }
826

827
  /**
828
   * Write a value directly into the local L1 cache without version bump,
829
   * without broadcast, without hot-key detection, and without reporting.
830
   * <p>
831
   * Existing entry metadata is preserved.  If no entry exists, a fresh
832
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
833
   * <p>
834
   * Pass {@code 0} for either TTL to use the configured default.
835
   *
836
   * @param cacheKey  the key to store
837
   * @param value     the value to cache
838
   * @param hardTtlMs hard TTL override (0 = use configured default)
839
   * @param softTtlMs soft TTL override (0 = use configured default)
840
   */
841
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
842
    if (invalidCacheKey(cacheKey)) {
3✔
843
      log.debug("putLocal: invalid cacheKey");
3✔
844
      return;
1✔
845
    }
846
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
847
      log.debug("putLocal: blocked by rule: {}", cacheKey);
4✔
848
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
849
    }
850

851
    long hardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
852
    long softTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
853

854
    caffeineCache
2✔
855
      .asMap()
7✔
856
      .compute(cacheKey, (k, existing) -> {
2✔
857
        if (existing instanceof CacheEntry ce) {
6✔
858
          CacheEntry updatedValue = ce.toBuilder().value(value).build();
6✔
859
          return expireManager.applyTtl(updatedValue, hardTtl, softTtl);
7✔
860
        }
861
        CacheEntry baseEntry = CacheEntry.builder()
2✔
862
          .value(value)
2✔
863
          .dataVersion(VERSION_DEFAULT)
2✔
864
          .isVersionDegraded(false)
2✔
865
          .decisionVersion(0L)
2✔
866
          .keyState(KeyState.NORMAL)
2✔
867
          .normalHardTtlMs(hardTtl)
2✔
868
          .normalSoftTtlMs(softTtl)
1✔
869
          .build();
2✔
870
        return expireManager.applyTtl(baseEntry, hardTtl, softTtl);
7✔
871
      });
872
  }
1✔
873

874
  /**
875
   * Execute a mutation, then invalidate L1 and broadcast.
876
   * Next {@link #get} will re-fetch from the reader.
877
   *
878
   * @param cacheKey the key to invalidate after mutation
879
   * @param mutation the mutation to execute
880
   */
881
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
882
    if (invalidCacheKey(cacheKey)) {
3✔
883
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
884
      return;
1✔
885
    }
886
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
887
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
888
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
889
    }
890
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
891
      try {
892
        mutation.run();
2✔
893
      } catch (Exception e) {
1✔
894
        log.error("putBeforeInvalidate mutation failed, skip invalidate and broadcast: {}", cacheKey, e);
5✔
895
        return;
1✔
896
      }
1✔
897
      // Drop local L1 immediately — subsequent reads on this instance must
898
      // re-fetch the mutated value via the reader.
899
      caffeineCache.invalidate(cacheKey);
4✔
900

901
      try {
902
        hotKeyExecutor.execute(() -> {
6✔
903
          try {
904
            var vr = versionController.nextVersion(cacheKey);
5✔
905
            cacheSyncPublisher.ifPresentOrElse(
7✔
906
              p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
907
              () -> log.debug("putBeforeInvalidate async: {}", NO_SYNC_PUBLISHER)
5✔
908
            );
909
          } catch (Exception ex) {
×
910
            log.error("putBeforeInvalidate async broadcast failed for key={}", cacheKey, ex);
×
911
          }
1✔
912
        });
1✔
913
      } catch (RejectedExecutionException ree) {
×
914
        log.warn(
×
915
          "putBeforeInvalidate executor rejected for key={}, peer invalidation deferred: {}",
916
          cacheKey,
917
          ree.getMessage()
×
918
        );
919
      }
1✔
920
    });
1✔
921
  }
1✔
922

923
  /**
924
   * Add a key pattern to the blacklist.
925
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
926
   *
927
   * @param cacheKey the key pattern to blacklist
928
   */
929
  public void addBlacklist(String cacheKey) {
930
    if (invalidCacheKey(cacheKey)) {
3✔
931
      log.debug("addBlacklist: invalid cacheKey '{}'", cacheKey);
4✔
932
      return;
1✔
933
    }
934
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
935
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK));
6✔
936
      log.info("Blacklist added: '{}'", cacheKey);
4✔
937
    });
1✔
938
  }
1✔
939

940
  /**
941
   * Add a key pattern to the whitelist.
942
   * Matching keys are allowed but bypass app-to-Worker reporting.
943
   *
944
   * @param cacheKey the key pattern to whitelist
945
   */
946
  public void addWhitelist(String cacheKey) {
947
    if (invalidCacheKey(cacheKey)) {
3✔
948
      log.debug("addWhitelist: invalid cacheKey '{}'", cacheKey);
4✔
949
      return;
1✔
950
    }
951
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
952
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT));
6✔
953
      log.info("Whitelist added: '{}'", cacheKey);
4✔
954
    });
1✔
955
  }
1✔
956

957
  /**
958
   * Remove a key pattern from the blacklist.
959
   *
960
   * @param cacheKey the key pattern to remove from the blacklist
961
   */
962
  public void unBlacklist(String cacheKey) {
963
    if (invalidCacheKey(cacheKey)) {
3✔
964
      log.debug("unBlacklist: invalid cacheKey '{}'", cacheKey);
4✔
965
      return;
1✔
966
    }
967
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
968
      if (ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK)) {
6!
969
        log.info("Blacklist removed: '{}'", cacheKey);
4✔
970
      }
971
    });
1✔
972
  }
1✔
973

974
  /**
975
   * Remove a key pattern from the whitelist.
976
   *
977
   * @param cacheKey the key pattern to remove from the whitelist
978
   */
979
  public void unWhitelist(String cacheKey) {
980
    if (invalidCacheKey(cacheKey)) {
3✔
981
      log.debug("unWhitelist: invalid cacheKey '{}'", cacheKey);
4✔
982
      return;
1✔
983
    }
984
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
985
      if (ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT)) {
6!
986
        log.info("Whitelist removed: '{}'", cacheKey);
4✔
987
      }
988
    });
1✔
989
  }
1✔
990

991
  /**
992
   * Evaluate all rules against the given key and return the first matching action.
993
   *
994
   * @param cacheKey the key to evaluate
995
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
996
   */
997
  public RuleAction evaluateRule(String cacheKey) {
998
    return ruleMatcher.evaluateRule(cacheKey);
5✔
999
  }
1000

1001
  /**
1002
   * Return a snapshot of all current rules in evaluation order.
1003
   *
1004
   * @return list of rules (immutable snapshot)
1005
   */
1006
  public List<Rule> getAllRules() {
1007
    return ruleMatcher.getAllRules();
4✔
1008
  }
1009

1010
  /**
1011
   * Remove all blacklist and whitelist rules.
1012
   */
1013
  public void clearAllRules() {
1014
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1015
  }
1✔
1016

1017
  /**
1018
   * Broadcast all local rules to peer instances via the sync exchange.
1019
   * Useful for initial synchronization when a new instance joins the cluster.
1020
   */
1021
  public void broadcastAllLocalRulesManually() {
1022
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1023
  }
1✔
1024

1025
  /**
1026
   * Estimated number of entries currently in the L1 cache.
1027
   *
1028
   * @return best-effort estimate of the current entry count
1029
   */
1030
  public long estimatedSize() {
1031
    return caffeineCache.estimatedSize();
4✔
1032
  }
1033

1034
  /**
1035
   * Return a snapshot of basic L1 cache statistics.
1036
   * <p>
1037
   * Hit/miss/eviction counters are populated only when Caffeine's
1038
   * {@code recordStats()} is enabled.  {@code estimatedSizeOfKeysCount} is always
1039
   * available.
1040
   *
1041
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
1042
   *         if stats recording is not enabled
1043
   */
1044
  public HotKeyCacheStats stats() {
1045
    CacheStats cs = caffeineCache.stats();
4✔
1046
    return new HotKeyCacheStats(
4✔
1047
      cs.hitCount(),
2✔
1048
      cs.missCount(),
2✔
1049
      cs.hitRate(),
2✔
1050
      cs.evictionCount(),
3✔
1051
      caffeineCache.estimatedSize()
2✔
1052
    );
1053
  }
1054

1055
  /**
1056
   * Check whether the given key is blacklisted.
1057
   *
1058
   * @param cacheKey the key to check
1059
   * @return {@code true} if a blacklist rule matches the key
1060
   */
1061

1062
  public boolean isBlacklisted(String cacheKey) {
1063
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1064
  }
1065

1066
  /**
1067
   * Check whether the given key is whitelisted (skips Worker reporting).
1068
   *
1069
   * @param cacheKey the key to check
1070
   * @return {@code true} if a whitelist rule matches the key
1071
   */
1072
  public boolean isWhitelisted(String cacheKey) {
1073
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1074
  }
1075

1076
  /**
1077
   * Invalidate all entries from the L1 cache without broadcasting.
1078
   * <p>
1079
   * This is an emergency flush — all cached values are removed immediately.
1080
   * No cross-instance sync messages are sent.
1081
   */
1082
  public void invalidateAllLocal() {
1083
    caffeineCache.invalidateAll();
3✔
1084
  }
1✔
1085

1086
  /**
1087
   * Return the underlying Caffeine cache for direct access.
1088
   *
1089
   * <p>This provides access to Caffeine-specific operations such as
1090
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
1091
   * caution — bypassing the HotKey orchestration layer (version tracking,
1092
   * broadcast, expiry management) can lead to inconsistent state.
1093
   *
1094
   * @return the raw Caffeine {@link Cache} instance
1095
   */
1096
  public Cache<String, Object> getLocalCache() {
1097
    return caffeineCache;
3✔
1098
  }
1099

1100
  /**
1101
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1102
   * <p>
1103
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1104
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1105
   * transparently returned as {@code null} to callers.
1106
   *
1107
   * @param value the raw value from a {@link CacheEntry}
1108
   * @return {@code null} if the sentinel, otherwise the original value
1109
   */
1110
  @Nullable
1111
  private static Object unwrapNull(@Nullable Object value) {
1112
    return value == NullValue.INSTANCE ? null : value;
7✔
1113
  }
1114
}
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