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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

83.67
/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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

446
      long effectiveHard = expireManager.resolveEffectiveHard(hardTtlMs);
5✔
447
      long effectiveSoft = expireManager.resolveEffectiveSoft(softTtlMs);
5✔
448

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

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

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

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

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

497
    return CacheEntry.builder()
3✔
498
      .value(value)
2✔
499
      .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
500
      .isVersionDegraded(false)
2✔
501
      .decisionVersion(0L)
2✔
502
      .hardTtlMs(hardTtlMs)
2✔
503
      .hardExpireAtMs(hardTtlExpireAtMs)
2✔
504
      .softTtlMs(softTtlMs)
2✔
505
      .softExpireAtMs(softTtlExpireAtMs)
2✔
506
      .keyState(state)
2✔
507
      .normalHardTtlMs(normalHardTtlMs)
2✔
508
      .normalSoftTtlMs(normalSoftTtlMs)
1✔
509
      .build();
1✔
510
  }
511

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

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

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

548
      caffeineCache
2✔
549
        .asMap()
8✔
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 expireManager.applyTtl(entry, hotHard, hotSoft).toBuilder().keyState(KeyState.HOT).build();
11✔
557
          }
558

559
          return expireManager.applyTtl(
×
560
            CacheEntry.builder()
×
561
              .value(val)
×
562
              .dataVersion(ce.getDataVersion())
×
563
              .isVersionDegraded(ce.isVersionDegraded())
×
564
              .decisionVersion(ce.getDecisionVersion())
×
565
              .keyState(KeyState.HOT)
×
566
              .normalHardTtlMs(ce.getNormalHardTtlMs())
×
567
              .normalSoftTtlMs(ce.getNormalSoftTtlMs())
×
568
              .build(),
×
569
            hotHard,
570
            hotSoft
571
          );
572
        });
573
      return true;
2✔
574
    }
575
    return false;
2✔
576
  }
577

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

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

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

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

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

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

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

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

705
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
706

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

718
        long effectiveHardTtl = expireManager.resolveEffectiveHard(hardTtlMs);
5✔
719
        long effectiveSoftTtl = expireManager.resolveEffectiveSoft(softTtlMs);
5✔
720

721
        try {
722
          var vr = versionController.nextVersion(cacheKey);
5✔
723

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

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

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

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

790
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
791

792
    long decisionVersion = 0L;
2✔
793
    String decisionNodeId = null;
2✔
794
    long decisionEpoch = 0L;
2✔
795
    long normalHardTtl = effectiveHardTtl;
2✔
796
    long normalSoftTtl = effectiveSoftTtl;
2✔
797

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

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

815
    return expireManager.applyTtl(
4✔
816
      CacheEntry.builder()
1✔
817
        .value(value != null ? value : NullValue.INSTANCE)
7✔
818
        .dataVersion(vr.dataVersion())
3✔
819
        .isVersionDegraded(vr.degraded())
3✔
820
        .decisionVersion(decisionVersion)
2✔
821
        .decisionNodeId(decisionNodeId)
2✔
822
        .decisionEpoch(decisionEpoch)
2✔
823
        .keyState(state)
2✔
824
        .normalHardTtlMs(normalHardTtl)
2✔
825
        .normalSoftTtlMs(normalSoftTtl)
1✔
826
        .build(),
3✔
827
      hardTtl,
828
      softTtl
829
    );
830
  }
831

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

856
    long hardTtl = expireManager.resolveEffectiveHard(hardTtlMs);
5✔
857
    long softTtl = expireManager.resolveEffectiveSoft(softTtlMs);
5✔
858

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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