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

Hyshmily / hotkey / 28290463292

27 Jun 2026 01:22PM UTC coverage: 91.227% (-0.7%) from 91.882%
28290463292

push

github

Hyshmily
test: fix CI tests

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

1250 of 1427 branches covered (87.6%)

Branch coverage included in aggregate %.

3544 of 3828 relevant lines covered (92.58%)

4.21 hits per line

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

84.42
/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.RejectedExecutionException;
44
import java.util.concurrent.TimeUnit;
45
import java.util.concurrent.atomic.AtomicBoolean;
46
import java.util.function.Supplier;
47
import lombok.RequiredArgsConstructor;
48
import lombok.extern.slf4j.Slf4j;
49

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

467
        caffeineCache
2✔
468
          .asMap()
9✔
469
          .compute(cacheKey, (k, existing) -> {
2✔
470
            if (isWorkerManagedEntry(existing)) {
3✔
471
              return existing;
2✔
472
            }
473
            return buildEntry(value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
474
          });
475

476
        if (!skipReport) {
2✔
477
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
478
        }
479
        log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
480
      } else {
1✔
481
        caffeineCache
2✔
482
          .asMap()
7✔
483
          .compute(cacheKey, (k, existing) -> {
2✔
484
            if (isWorkerManagedEntry(existing)) {
3✔
485
              return existing;
2✔
486
            }
487
            return buildEntry(value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
488
          });
489

490
        if (!skipReport) {
2✔
491
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
492
        }
493
        log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
494
      }
495
      return value;
2✔
496
    });
497
  }
498

499
  private <T> CacheEntry buildEntry(
500
    T value,
501
    long hardTtlMs,
502
    long softTtlMs,
503
    KeyState state,
504
    long normalHardTtlMs,
505
    long normalSoftTtlMs
506
  ) {
507
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
508
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
509

510
    return CacheEntry.builder()
3✔
511
      .value(value)
2✔
512
      .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
513
      .isVersionDegraded(false)
2✔
514
      .decisionVersion(0L)
2✔
515
      .hardTtlMs(hardTtlMs)
2✔
516
      .hardExpireAtMs(hardTtlExpireAtMs)
2✔
517
      .softTtlMs(softTtlMs)
2✔
518
      .softExpireAtMs(softTtlExpireAtMs)
2✔
519
      .keyState(state)
2✔
520
      .normalHardTtlMs(normalHardTtlMs)
2✔
521
      .normalSoftTtlMs(normalSoftTtlMs)
1✔
522
      .build();
1✔
523
  }
524

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

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

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

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

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

598
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
599
      // Local L1 must be dropped synchronously so subsequent reads see the
600
      // invalidation immediately, even if the executor is saturated.
601
      caffeineCache.invalidate(cacheKey);
4✔
602

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

630
  /**
631
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
632
   * Invalid keys (null or blank) are silently skipped.
633
   *
634
   * @param cacheKeys the keys to invalidate
635
   */
636
  public void invalidateAll(Collection<String> cacheKeys) {
637
    List<String> validKeys = cacheKeys
1✔
638
      .stream()
2✔
639
      .filter(k -> !invalidCacheKey(k))
8✔
640
      .toList();
2✔
641
    if (validKeys.isEmpty()) {
3✔
642
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
643
      return;
1✔
644
    }
645

646
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
647
      caffeineCache.invalidateAll(validKeys);
4✔
648
      cacheSyncPublisher.ifPresentOrElse(
7✔
649
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
650
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
651
      );
652
    });
1✔
653
  }
1✔
654

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

677
  /**
678
   * Write-through: execute the writer, then update L1 and broadcast.
679
   * Uses effective hard/soft TTL from configuration.
680
   *
681
   * @param cacheKey the key to write
682
   * @param value    the value to cache
683
   * @param writer   the data-source mutation to execute before caching
684
   * @param <T>      the value type
685
   * @throws HotKeyBlockedException when the key matches a blacklist rule
686
   */
687
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
688
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
689
  }
1✔
690

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

712
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
713

714
    TransactionSupport.runAsyncAfterCommit(
11✔
715
      () -> {
716
        try {
717
          writer.run();
2✔
718
          writerOk.compareAndSet(false, true);
5✔
719
        } catch (Exception e) {
×
720
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
721
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
722
          return;
×
723
        }
1✔
724

725
        long effectiveHardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
726
        long effectiveSoftTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
727

728
        try {
729
          var vr = versionController.nextVersion(cacheKey);
5✔
730

731
          caffeineCache
2✔
732
            .asMap()
8✔
733
            .compute(cacheKey, (k, existing) ->
2✔
734
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
735
            );
736

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

760
          caffeineCache
×
761
            .asMap()
×
762
            .compute(cacheKey, (k, existing) ->
×
763
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
764
            );
765
          cacheSyncPublisher.ifPresent(p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()));
×
766
        }
1✔
767
      },
1✔
768
      hotKeyExecutor
769
    );
770
  }
1✔
771

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

796
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
797

798
    long normalHardTtl = 0;
2✔
799
    long normalSoftTtl = 0;
2✔
800

801
    long decisionVersion = (existing instanceof CacheEntry entry) ? entry.getDecisionVersion() : 0L;
11✔
802
    String decisionNodeId = (existing instanceof CacheEntry entry) ? entry.getDecisionNodeId() : null;
11✔
803
    long decisionEpoch = (existing instanceof CacheEntry entry) ? entry.getDecisionEpoch() : 0L;
11✔
804

805
    if (existing instanceof CacheEntry) {
3✔
806
      normalHardTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalHardTtlMs() : effectiveHardTtl;
7!
807
    }
808
    if (existing instanceof CacheEntry) {
3✔
809
      normalSoftTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalSoftTtlMs() : effectiveSoftTtl;
7!
810
    }
811

812
    return CacheEntry.builder()
2✔
813
      .value(value != null ? value : NullValue.INSTANCE)
7✔
814
      .dataVersion(vr.dataVersion())
3✔
815
      .isVersionDegraded(vr.degraded())
3✔
816
      .decisionVersion(decisionVersion)
2✔
817
      .decisionNodeId(decisionNodeId)
2✔
818
      .decisionEpoch(decisionEpoch)
1✔
819
      .hardTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotHardTtlMs() : effectiveHardTtl)
9✔
820
      .hardExpireAtMs(
1✔
821
        state == KeyState.HOT
3✔
822
          ? expireManager.computeHotHardExpireAt()
4✔
823
          : expireManager.computeHardExpireAt(effectiveHardTtl)
4✔
824
      )
825
      .softTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotSoftTtlMs() : effectiveSoftTtl)
9✔
826
      .softExpireAtMs(
2✔
827
        state == KeyState.HOT
3✔
828
          ? expireManager.computeHotSoftExpireAt()
4✔
829
          : expireManager.computeSoftExpireAt(effectiveSoftTtl)
4✔
830
      )
831
      .keyState(state)
2✔
832
      .normalHardTtlMs(normalHardTtl)
2✔
833
      .normalSoftTtlMs(normalSoftTtl)
1✔
834
      .build();
1✔
835
  }
836

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

861
    long hardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
862
    long softTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
863

864
    caffeineCache
2✔
865
      .asMap()
7✔
866
      .compute(cacheKey, (k, existing) -> {
2✔
867
        if (existing instanceof CacheEntry ce) {
6✔
868
          return ce
2✔
869
            .toBuilder()
2✔
870
            .value(value)
2✔
871
            .hardTtlMs(hardTtl)
2✔
872
            .softTtlMs(softTtl)
4✔
873
            .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
5✔
874
            .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
2✔
875
            .build();
1✔
876
        }
877
        return CacheEntry.builder()
3✔
878
          .value(value)
2✔
879
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
880
          .isVersionDegraded(false)
2✔
881
          .decisionVersion(0L)
2✔
882
          .hardTtlMs(hardTtl)
4✔
883
          .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
3✔
884
          .softTtlMs(softTtl)
4✔
885
          .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
3✔
886
          .keyState(KeyState.NORMAL)
2✔
887
          .normalHardTtlMs(hardTtl)
2✔
888
          .normalSoftTtlMs(softTtl)
1✔
889
          .build();
1✔
890
      });
891
  }
1✔
892

893
  /**
894
   * Execute a mutation, then invalidate L1 and broadcast.
895
   * Next {@link #get} will re-fetch from the reader.
896
   *
897
   * @param cacheKey the key to invalidate after mutation
898
   * @param mutation the mutation to execute
899
   */
900
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
901
    if (invalidCacheKey(cacheKey)) {
3✔
902
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
903
      return;
1✔
904
    }
905
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
906
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
907
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
908
    }
909
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
910
      try {
911
        mutation.run();
2✔
912
      } catch (Exception e) {
1✔
913
        log.error("putBeforeInvalidate mutation failed, skip invalidate and broadcast: {}", cacheKey, e);
5✔
914
        return;
1✔
915
      }
1✔
916
      // Drop local L1 immediately — subsequent reads on this instance must
917
      // re-fetch the mutated value via the reader.
918
      caffeineCache.invalidate(cacheKey);
4✔
919

920
      try {
921
        hotKeyExecutor.execute(() -> {
6✔
922
          try {
923
            var vr = versionController.nextVersion(cacheKey);
5✔
924
            cacheSyncPublisher.ifPresentOrElse(
7✔
925
              p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
926
              () -> log.debug("putBeforeInvalidate async: {}", NO_SYNC_PUBLISHER)
5✔
927
            );
928
          } catch (Exception ex) {
×
929
            log.error("putBeforeInvalidate async broadcast failed for key={}", cacheKey, ex);
×
930
          }
1✔
931
        });
1✔
932
      } catch (RejectedExecutionException ree) {
×
933
        log.warn(
×
934
          "putBeforeInvalidate executor rejected for key={}, peer invalidation deferred: {}",
935
          cacheKey,
936
          ree.getMessage()
×
937
        );
938
      }
1✔
939
    });
1✔
940
  }
1✔
941

942
  /**
943
   * Add a key pattern to the blacklist.
944
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
945
   *
946
   * @param cacheKey the key pattern to blacklist
947
   */
948
  public void addBlacklist(String cacheKey) {
949
    if (invalidCacheKey(cacheKey)) {
3✔
950
      log.debug("blacklist: invalid cacheKey");
3✔
951
      return;
1✔
952
    }
953
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK)));
11✔
954
  }
1✔
955

956
  /**
957
   * Add a key pattern to the whitelist.
958
   * Matching keys are allowed but bypass app-to-Worker reporting.
959
   *
960
   * @param cacheKey the key pattern to whitelist
961
   */
962
  public void addWhitelist(String cacheKey) {
963
    if (invalidCacheKey(cacheKey)) {
3✔
964
      log.debug("whitelist: invalid cacheKey");
3✔
965
      return;
1✔
966
    }
967
    TransactionSupport.runNowOrAfterCommit(() ->
4✔
968
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT))
7✔
969
    );
970
  }
1✔
971

972
  /**
973
   * Remove a key pattern from the blacklist.
974
   *
975
   * @param cacheKey the key pattern to remove from the blacklist
976
   */
977
  public void unBlacklist(String cacheKey) {
978
    if (invalidCacheKey(cacheKey)) {
3✔
979
      log.debug("unblacklist: invalid cacheKey");
3✔
980
      return;
1✔
981
    }
982
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK));
11✔
983
  }
1✔
984

985
  /**
986
   * Remove a key pattern from the whitelist.
987
   *
988
   * @param cacheKey the key pattern to remove from the whitelist
989
   */
990
  public void unWhitelist(String cacheKey) {
991
    if (invalidCacheKey(cacheKey)) {
3✔
992
      log.debug("unwhitelist: invalid cacheKey");
3✔
993
      return;
1✔
994
    }
995
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT));
11✔
996
  }
1✔
997

998
  /**
999
   * Evaluate all rules against the given key and return the first matching action.
1000
   *
1001
   * @param cacheKey the key to evaluate
1002
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
1003
   */
1004
  public RuleAction evaluateRule(String cacheKey) {
1005
    return ruleMatcher.evaluateRule(cacheKey);
5✔
1006
  }
1007

1008
  /**
1009
   * Return a snapshot of all current rules in evaluation order.
1010
   *
1011
   * @return list of rules (immutable snapshot)
1012
   */
1013
  public List<Rule> getAllRules() {
1014
    return ruleMatcher.getAllRules();
4✔
1015
  }
1016

1017
  /**
1018
   * Remove all blacklist and whitelist rules.
1019
   */
1020
  public void clearAllRules() {
1021
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1022
  }
1✔
1023

1024
  /**
1025
   * Broadcast all local rules to peer instances via the sync exchange.
1026
   * Useful for initial synchronization when a new instance joins the cluster.
1027
   */
1028
  public void broadcastAllLocalRulesManually() {
1029
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1030
  }
1✔
1031

1032
  /**
1033
   * Estimated number of entries currently in the L1 cache.
1034
   *
1035
   * @return best-effort estimate of the current entry count
1036
   */
1037
  public long estimatedSize() {
1038
    return caffeineCache.estimatedSize();
4✔
1039
  }
1040

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

1062
  /**
1063
   * Check whether the given key is blacklisted.
1064
   *
1065
   * @param cacheKey the key to check
1066
   * @return {@code true} if a blacklist rule matches the key
1067
   */
1068

1069
  public boolean isBlacklisted(String cacheKey) {
1070
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1071
  }
1072

1073
  /**
1074
   * Check whether the given key is whitelisted (skips Worker reporting).
1075
   *
1076
   * @param cacheKey the key to check
1077
   * @return {@code true} if a whitelist rule matches the key
1078
   */
1079
  public boolean isWhitelisted(String cacheKey) {
1080
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1081
  }
1082

1083
  /**
1084
   * Invalidate all entries from the L1 cache without broadcasting.
1085
   * <p>
1086
   * This is an emergency flush — all cached values are removed immediately.
1087
   * No cross-instance sync messages are sent.
1088
   */
1089
  public void invalidateAllLocal() {
1090
    caffeineCache.invalidateAll();
3✔
1091
  }
1✔
1092

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

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