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

Hyshmily / hotkey / 28916241081

08 Jul 2026 03:53AM UTC coverage: 89.468% (-0.7%) from 90.12%
28916241081

push

github

Hyshmily
refactor(core): extract central dispatcher and broadcast buffer

Introduce CentralDispatcher for centralized event routing and BroadcastBuffer for cache synchronization. Refactor HotKeyCache, SlidingWindowDetector, and related components to use the new dispatching mechanism, improving separation of concerns and reducing coupling.

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

1328 of 1564 branches covered (84.91%)

Branch coverage included in aggregate %.

102 of 134 new or added lines in 17 files covered. (76.12%)

3 existing lines in 2 files now uncovered.

3769 of 4133 relevant lines covered (91.19%)

4.21 hits per line

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

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

18
import static io.github.hyshmily.hotkey.cache.cachesupport.CacheKeysPolicy.invalidCacheKey;
19
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.VERSION_DEFAULT;
20

21
import com.github.benmanes.caffeine.cache.Cache;
22
import com.github.benmanes.caffeine.cache.stats.CacheStats;
23
import io.github.hyshmily.hotkey.Internal;
24
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
25
import io.github.hyshmily.hotkey.cache.annotationsupporter.NullValue;
26
import io.github.hyshmily.hotkey.cache.cachesupport.ExpireManager;
27
import io.github.hyshmily.hotkey.cache.cachesupport.SingleFlight;
28
import io.github.hyshmily.hotkey.cache.cachesupport.TransactionSupport;
29
import io.github.hyshmily.hotkey.cache.codec.CacheCompressor;
30
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
31
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
32
import io.github.hyshmily.hotkey.model.CacheEntry;
33
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
34
import io.github.hyshmily.hotkey.model.KeyState;
35
import io.github.hyshmily.hotkey.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.HealthView;
39
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
40
import io.github.hyshmily.hotkey.sync.local.SyncMessage;
41
import io.github.hyshmily.hotkey.util.TimeSource;
42
import io.github.hyshmily.hotkey.util.version.VersionController;
43
import io.github.hyshmily.hotkey.util.version.VersionGuard;
44
import jakarta.annotation.Nullable;
45
import java.io.IOException;
46
import java.util.Collection;
47
import java.util.List;
48
import java.util.Optional;
49
import java.util.concurrent.Executor;
50
import java.util.concurrent.RejectedExecutionException;
51
import java.util.concurrent.TimeUnit;
52
import java.util.concurrent.atomic.AtomicBoolean;
53
import java.util.function.Supplier;
54
import lombok.Builder;
55
import lombok.RequiredArgsConstructor;
56
import lombok.extern.slf4j.Slf4j;
57

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

73
  /** Local TopK detector (HotKeyDetector) for identifying hot keys. */
74
  private final HotKeyDetector hotKeyDetector;
75
  /** Underlying L1 Caffeine cache storing {@link CacheEntry} or raw values. */
76
  private final Cache<String, Object> caffeineCache;
77
  /** Deduplicator preventing concurrent in-flight loads for the same key. */
78
  private final SingleFlight singleFlight;
79
  /** Manages hard and soft TTL computation for cache entries. */
80
  private final ExpireManager expireManager;
81
  /** Executor for async cache operations (promotion, soft refresh). */
82
  private final Executor hotKeyExecutor;
83

84
  /** Central dispatcher for all external communication (report + broadcast). */
85
  private final CentralDispatcher dispatcher;
86

87
  /** Matches cache keys against blacklist/whitelist rules. */
88
  private final RuleMatcher ruleMatcher;
89
  /** Manages data version generation for mutation ordering. */
90
  private final VersionController versionController;
91

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

95
  /** Cached view of Worker cluster health, used for COOL promotion decisions. */
96
  private final HealthView healthView;
97

98
  /** Compressor for L1 cache values. */
99
  private final CacheCompressor compressor;
100

101
  @Builder
102
  static class GuardReport {
103

104
    RuleAction action;
105
    boolean isSkipReport;
106
  }
107

108
  /**
109
   * Guard against invalid cache keys and blocked keys.
110
   *
111
   * @param cacheKey the cache key to check
112
   * @return the {@link RuleAction} for a valid key, or {@code null} if the key is invalid
113
   * @throws HotKeyBlockedException when the key matches a blocklist rule
114
   */
115
  @Nullable
116
  private GuardReport preGuard(String cacheKey) {
117
    if (invalidCacheKey(cacheKey)) {
3✔
118
      return null;
2✔
119
    }
120
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
121
    if (action == RuleAction.BLOCK) {
3✔
122
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
123
    }
124
    return GuardReport.builder().action(action).isSkipReport(action == RuleAction.ALLOW_NO_REPORT).build();
12✔
125
  }
126

127
  /**
128
   * Secondary BLOCK check (TOCTOU guard for paths where the key was already validated).
129
   *
130
   * @param cacheKey the cache key to check
131
   * @throws HotKeyBlockedException when the key matches a blocklist rule
132
   */
133
  private void guardBlocked(String cacheKey) {
134
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
135
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
136
    }
137
  }
1✔
138

139
  /**
140
   * Check whether an existing cache entry is managed by the Worker (HOT or COOL).
141
   * Worker-managed entries preserve their original normal TTLs through writes.
142
   *
143
   * @param existing the existing cache entry (maybe {@code null} or a raw value)
144
   * @return {@code true} if the entry is a {@link CacheEntry} with state HOT or COOL
145
   */
146
  private static boolean isWorkerManaged(Object existing) {
147
    return (
1✔
148
      existing instanceof CacheEntry entry &&
7✔
149
      (entry.getKeyState() == KeyState.HOT || entry.getKeyState() == KeyState.COOL)
10✔
150
    );
151
  }
152

153
  /**
154
   * Check whether a key is currently tracked as a local hot key in L1.
155
   *
156
   * @param cacheKey the key to inspect
157
   * @return {@code true} if the key exists in L1 with {@link KeyState#HOT}
158
   */
159
  public boolean isLocalHotKey(String cacheKey) {
160
    if (invalidCacheKey(cacheKey)) {
3✔
161
      log.debug("isLocalHotKey: invalid cacheKey");
3✔
162
      return false;
2✔
163
    }
164
    Object entry = caffeineCache.getIfPresent(cacheKey);
5✔
165
    return isHotInCache(entry) || hotKeyDetector.contains(cacheKey);
13!
166
  }
167

168
  /**
169
   * Check whether an entry in the local cache is a non-expired HOT entry.
170
   *
171
   * @param entry the raw value from Caffeine (maybe {@link CacheEntry} or {@code null})
172
   * @return {@code true} if the entry is a valid, non-expired HOT
173
   */
174
  private boolean isHotInCache(Object entry) {
175
    if (entry instanceof CacheEntry ce && !expireManager.isLogicallyExpired(ce)) {
11✔
176
      return KeyState.HOT == ce.getKeyState();
8✔
177
    }
178
    return false;
2✔
179
  }
180

181
  /**
182
   * Whether the given {@link KeyState} is eligible for local promotion to HOT.
183
   * <p>
184
   * {@link KeyState#NORMAL} entries are always eligible: when the local TopK
185
   * detects them as hot, they get promoted to HOT with longer TTLs.
186
   * <p>
187
   * {@link KeyState#COOL} entries are only eligible when no Worker shard is
188
   * alive ( returns {@code false}).
189
   * This provides graceful degradation — when the Worker cluster is unavailable,
190
   * the local TopK drives TTL decisions instead of preserving stale Worker verdicts.
191
   * Once a Worker comes back online and broadcasts a new decision, it overrides
192
   * the local promotion via {@code decisionVersion} comparison.
193
   * <p>
194
   * {@link KeyState#HOT} entries are never eligible — they already have the
195
   * longest TTLs.
196
   *
197
   * @param state the current key state of the cache entry
198
   * @return {@code true} if the entry may be promoted by local TopK
199
   */
200
  private boolean isPromotableState(KeyState state) {
201
    return state == KeyState.NORMAL || (state == KeyState.COOL && !healthView.isClusterHealthy());
14✔
202
  }
203

204
  /**
205
   * Execute a cache operation with standardized error handling: {@link HotKeyBlockedException}
206
   * is rethrown, all other {@link RuntimeException} are logged and swallowed.
207
   *
208
   * @param cacheKey the key being accessed (used in the error log)
209
   * @param action   the cache operation to execute
210
   * @param <T>      the value type
211
   * @return the result of {@code action}, or {@link Optional#empty()} on error
212
   */
213
  private <T> Optional<T> executeWithErrorHandling(String cacheKey, Supplier<Optional<T>> action) {
214
    try {
215
      return action.get();
4✔
216
    } catch (HotKeyBlockedException e) {
×
217
      throw e;
×
218
    } catch (RuntimeException e) {
×
219
      log.error("HotKeyCache internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
220
      return Optional.empty();
×
221
    }
222
  }
223

224
  /**
225
   * Look up a cached value without loading or triggering hot-key detection.
226
   * Unlike {@link #get}, this method never invokes the reader or SingleFlight.
227
   *
228
   * @param cacheKey the key to inspect
229
   * @return an {@link Optional} containing the raw value if present
230
   */
231
  @SuppressWarnings("unchecked")
232
  public <T> Optional<T> peek(String cacheKey) {
233
    if (preGuard(cacheKey) == null) return Optional.empty();
6✔
234
    return executeWithErrorHandling(cacheKey, () ->
7✔
235
      Optional.ofNullable(caffeineCache.getIfPresent(cacheKey)).map(raw ->
9✔
236
        raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw
13✔
237
      )
238
    );
239
  }
240

241
  /**
242
   * Get a value from L1 or load it via the reader.
243
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
244
   *
245
   * @param cacheKey the key to retrieve
246
   * @param reader   the value supplier for cache misses
247
   * @param <T>      the value type
248
   * @return an {@link Optional} containing the cached or loaded value
249
   * @throws HotKeyBlockedException when the key matches a blacklist rule
250
   */
251
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
252
    return get(cacheKey, reader, 0L, 0L);
7✔
253
  }
254

255
  /**
256
   * Get with explicit TTL overrides.
257
   * Pass 0 to use the configured default for that TTL type.
258
   *
259
   * @param cacheKey  the key to retrieve
260
   * @param reader    the value supplier for cache misses
261
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
262
   * @param softTtlMs soft TTL override (0 = use configured default)
263
   * @param <T>       the value type
264
   * @return an {@link Optional} containing the cached or loaded value
265
   * @throws HotKeyBlockedException when the key matches a blacklist rule
266
   */
267
  @SuppressWarnings("unchecked")
268
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
269
    GuardReport g = preGuard(cacheKey);
4✔
270
    if (g == null) return Optional.empty();
4✔
271

272
    boolean skipReport = g.isSkipReport;
3✔
273

274
    return (Optional<T>) executeWithErrorHandling(cacheKey, () ->
11✔
275
      Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
276
        .flatMap(raw -> handleCacheHit(cacheKey, raw, hardTtlMs, softTtlMs, skipReport))
16✔
277
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport))
9✔
278
    );
279
  }
280

281
  /**
282
   * Check a cache hit: verify it is not logically expired, unwrap the value,
283
   * then run {@link #process} for promotion and reporting.
284
   * <p>Extracted from {@link #get} to name the flatMap step.
285
   */
286
  private <T> Optional<T> handleCacheHit(
287
    String cacheKey,
288
    Object raw,
289
    long hardTtlMs,
290
    long softTtlMs,
291
    boolean skipReport
292
  ) {
293
    if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, raw)) {
6✔
294
      return Optional.empty();
2✔
295
    }
296
    @SuppressWarnings("unchecked")
297
    T val = raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw;
13✔
298
    return process(cacheKey, raw, val, hardTtlMs, softTtlMs, skipReport);
9✔
299
  }
300

301
  /**
302
   * Get with soft-expire (stale-while-revalidate). Returns cached value immediately
303
   * even if soft TTL expired, while triggering async refresh in background.
304
   * Only HOT and COOL entries are subject to soft expire.
305
   *
306
   * @param cacheKey the key to retrieve
307
   * @param reader   the value supplier for cache misses / refreshes
308
   * @param <T>      the value type
309
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
310
   * @throws HotKeyBlockedException when the key matches a blacklist rule
311
   */
312
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader) {
313
    return getWithSoftExpire(cacheKey, reader, 0L, 0L);
7✔
314
  }
315

316
  /**
317
   * Get with soft-expire and explicit soft TTL override.
318
   *
319
   * @param cacheKey  the key to retrieve
320
   * @param reader    the value supplier for cache misses / refreshes
321
   * @param softTtlMs soft TTL override (0 = use configured default)
322
   * @param <T>       the value type
323
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
324
   * @throws HotKeyBlockedException when the key matches a blacklist rule
325
   */
326
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
327
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
328
  }
329

330
  /**
331
   * Get with soft-expire and explicit hard/soft TTL overrides.
332
   *
333
   * @param cacheKey  the key to retrieve
334
   * @param reader    the value supplier for cache misses / refreshes
335
   * @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})
336
   * @param softTtlMs soft TTL override (0 = use configured default)
337
   * @param <T>       the value type
338
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
339
   * @throws HotKeyBlockedException when the key matches a blacklist rule
340
   */
341
  @SuppressWarnings("all")
342
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
343
    GuardReport g = preGuard(cacheKey);
4✔
344
    if (g == null) return Optional.empty();
4✔
345

346
    boolean skipReport = g.isSkipReport;
3✔
347

348
    if (!expireManager.isSoftExpireEnabled()) {
4✔
349
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
350
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
351
    }
352
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
353
    return executeWithErrorHandling(cacheKey, () ->
12✔
354
      Optional.ofNullable(raw)
10✔
355
        .flatMap(v -> handleSoftExpireHit(cacheKey, v, reader, hardTtlMs, softTtlMs, skipReport))
17✔
356
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport))
9✔
357
    );
358
  }
359

360
  private <T> Optional<T> handleSoftExpireHit(
361
    String cacheKey,
362
    Object raw,
363
    Supplier<T> reader,
364
    long hardTtlMs,
365
    long softTtlMs,
366
    boolean skipReport
367
  ) {
368
    if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, raw)) {
6✔
369
      return Optional.empty();
2✔
370
    }
371
    @SuppressWarnings("unchecked")
372
    T cached = raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw;
13✔
373
    triggerSoftExpireRefresh(cacheKey, raw, reader, softTtlMs);
6✔
374
    return process(cacheKey, raw, cached, hardTtlMs, softTtlMs, skipReport);
9✔
375
  }
376

377
  /**
378
   * Trigger a background refresh if the raw value is a worker-managed entry
379
   * and its soft TTL has expired.
380
   *
381
   * @param cacheKey  the cache key
382
   * @param raw       the raw value from Caffeine (may be {@link CacheEntry} or bare)
383
   * @param reader    the value supplier for the refresh
384
   * @param softTtlMs per-call soft TTL override (0 = use configured default)
385
   */
386
  private void triggerSoftExpireRefresh(String cacheKey, Object raw, Supplier<?> reader, long softTtlMs) {
387
    if (!isWorkerManaged(raw)) return;
4✔
388

389
    CacheEntry ce = (CacheEntry) raw;
3✔
390
    if (!expireManager.isSoftExpired(ce)) return;
6✔
391

392
    long effectiveSoft = computeSoftTtlForRefresh(softTtlMs, ce);
5✔
393
    expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
394
  }
1✔
395

396
  /**
397
   * Compute the effective soft TTL to use for a background refresh.
398
   * <p>
399
   * Priority: per-call override > per-key-state hot default > per-entry normal TTL > global default.
400
   *
401
   * @param softTtlMs per-call soft TTL override (0 = use configured default)
402
   * @param ce        the cache entry to derive key-state and normal-soft defaults from
403
   * @return the effective soft TTL in milliseconds
404
   */
405
  private long computeSoftTtlForRefresh(long softTtlMs, CacheEntry ce) {
406
    if (softTtlMs > 0) return softTtlMs;
6✔
407
    if (KeyState.HOT == ce.getKeyState()) return expireManager.getEffectiveHotSoftTtlMs();
4!
408
    if (ce.getNormalSoftTtlMs() > 0) return ce.getNormalSoftTtlMs();
8✔
409
    return expireManager.getEffectiveSoftTtlMs();
4✔
410
  }
411

412
  /**
413
   * Process a cache hit: trigger local hot-key promotion/renewal and report
414
   * to Worker, then re-check logical expiry (TOCTOU guard) after side effects.
415
   * <p>Extracted from {@link #get} and {@link #getWithSoftExpire} to eliminate
416
   * code duplication.
417
   *
418
   * @param cacheKey  the cache key
419
   * @param raw       the raw value from Caffeine (maybe {@link CacheEntry} or bare)
420
   * @param cached    the unwrapped cached value
421
   * @param hardTtlMs hard TTL override
422
   * @param softTtlMs soft TTL override
423
   * @param skipReport if {@code true}, skip app-to-Worker reporting
424
   * @param <T>       the value type
425
   * @return an {@link Optional} containing the cached value, or empty if logically expired
426
   */
427
  private <T> Optional<T> process(
428
    String cacheKey,
429
    Object raw,
430
    T cached,
431
    long hardTtlMs,
432
    long softTtlMs,
433
    boolean skipReport
434
  ) {
435
    dispatcher.record(cacheKey, skipReport);
5✔
436

437
    boolean wasProcessed = processLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
438

439
    // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
440
    if (wasProcessed || !skipReport) {
4✔
441
      Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
442
      if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
6!
443
        return Optional.empty();
×
444
      }
445
    }
446

447
    return Optional.ofNullable(cached);
3✔
448
  }
449

450
  /**
451
   * Load via SingleFlight, detect hot key, cache with HOT or NORMAL TTL, and return value.
452
   *
453
   * @param cacheKey   the key to load
454
   * @param reader     the value supplier
455
   * @param hardTtlMs  hard TTL override (0 = use configured default)
456
   * @param softTtlMs  soft TTL override (0 = use configured default)
457
   * @param skipReport if {@code true}, skip reporting to Worker
458
   */
459
  private <T> Optional<T> loadAndCache(
460
    String cacheKey,
461
    Supplier<T> reader,
462
    long hardTtlMs,
463
    long softTtlMs,
464
    boolean skipReport
465
  ) {
466
    guardBlocked(cacheKey);
3✔
467
    Optional<T> result = singleFlight.load(cacheKey, reader);
6✔
468

469
    if (result.isEmpty()) {
3!
470
      return mapEmpty(cacheKey);
×
471
    }
472
    T value = result.get();
3✔
473
    return Optional.of(processLoaded(cacheKey, value, hardTtlMs, softTtlMs, skipReport));
9✔
474
  }
475

476
  /**
477
   * Handle the case when SingleFlight returns empty: either return a stale
478
   * entry when the circuit breaker is open, or cache a {@link NullValue}
479
   * sentinel with a short TTL and return {@link Optional#empty()}.
480
   *
481
   * @param cacheKey the key that was loaded (resulted in a null value)
482
   * @param <T>      the expected value type
483
   * @return a stale value if the circuit breaker is open and a cached entry
484
   *         exists; {@link Optional#empty()} otherwise (a NullValue sentinel
485
   *         is written to L1 in both cases)
486
   */
487
  @SuppressWarnings("unchecked")
488
  private <T> Optional<T> mapEmpty(String cacheKey) {
489
    if (singleFlight.isBreakerOpen()) {
×
490
      Object stale = caffeineCache.getIfPresent(cacheKey);
×
491

492
      if (stale != null) {
×
493
        T val = stale instanceof CacheEntry ce ? (T) unwrapValue(ce.getValue()) : (T) stale;
×
494
        log.debug("CB open, returning stale entry for key={}", cacheKey);
×
495
        return Optional.ofNullable(val);
×
496
      }
497
    }
498
    putNullValueEntry(cacheKey);
×
499
    return Optional.empty();
×
500
  }
501

502
  /**
503
   * Cache a {@link NullValue} sentinel in L1 with a short TTL so that
504
   * subsequent reads return empty without hitting the reader.
505
   *
506
   * @param cacheKey the key to store the null sentinel for
507
   */
508
  private void putNullValueEntry(String cacheKey) {
509
    long nullTtlMs = TimeUnit.SECONDS.toMillis(hotKeyProperties.getNullValueTtlSeconds());
×
510
    long nullExpireAtMs = expireManager.computeNullExpireAt(nullTtlMs);
×
511
    caffeineCache.put(
×
512
      cacheKey,
513
      expireManager.createBuilder(
×
514
        NullValue.INSTANCE,
515
        VERSION_DEFAULT,
516
        false,
517
        0L,
518
        nullTtlMs,
519
        0L,
520
        nullExpireAtMs,
521
        0L,
522
        0L,
523
        0L,
524
        KeyState.NORMAL
525
      )
526
    );
527
  }
×
528

529
  /**
530
   * Process a successfully loaded value: re-check blacklist, detect hot key via
531
   * HeavyKeeper, store in L1 with HOT or NORMAL TTL, and optionally report to Worker.
532
   *
533
   * @param cacheKey   the key to cache
534
   * @param value      the loaded value (must not be null at this point)
535
   * @param hardTtlMs  hard TTL override (0 = use configured default)
536
   * @param softTtlMs  soft TTL override (0 = use configured default)
537
   * @param skipReport if {@code true}, skip reporting to Worker
538
   * @param <T>        the value type
539
   * @return the loaded value (unchanged)
540
   */
541
  private <T> T processLoaded(String cacheKey, T value, long hardTtlMs, long softTtlMs, boolean skipReport) {
542
    guardBlocked(cacheKey);
3✔
543

544
    long effectiveHard = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
545
    long effectiveSoft = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
546

547
    dispatcher.record(cacheKey, skipReport);
5✔
548
    if (hotKeyDetector.contains(cacheKey)) {
5✔
549
      long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
550
      long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
551

552
      loadCacheEntry(cacheKey, value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
553
    } else {
1✔
554
      loadCacheEntry(cacheKey, value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
555
    }
556
    return value;
2✔
557
  }
558

559
  /**
560
   * Atomically insert or update the entry in L1 via {@code compute}, skipping
561
   * the update if the existing entry is Worker-managed (HOT or COOL).
562
   *
563
   * @param cacheKey      the key to store
564
   * @param value         the value to cache
565
   * @param hardTtlMs     the hard TTL for this entry
566
   * @param softTtlMs     the soft TTL for this entry
567
   * @param state         the key state to assign (HOT or NORMAL)
568
   * @param normalHardTtl the normal (non-hot) hard TTL to preserve for demotion
569
   * @param normalSoftTtl the normal (non-hot) soft TTL to preserve for demotion
570
   */
571
  private void loadCacheEntry(
572
    String cacheKey,
573
    Object value,
574
    long hardTtlMs,
575
    long softTtlMs,
576
    KeyState state,
577
    long normalHardTtl,
578
    long normalSoftTtl
579
  ) {
580
    caffeineCache
2✔
581
      .asMap()
10✔
582
      .compute(cacheKey, (k, existing) -> {
2✔
583
        if (isWorkerManaged(existing)) {
3✔
584
          return existing;
2✔
585
        }
586
        return buildEntry(value, hardTtlMs, softTtlMs, state, normalHardTtl, normalSoftTtl);
9✔
587
      });
588
  }
1✔
589

590
  private <T> CacheEntry buildEntry(
591
    T value,
592
    long hardTtlMs,
593
    long softTtlMs,
594
    KeyState state,
595
    long normalHardTtlMs,
596
    long normalSoftTtlMs
597
  ) {
598
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
599
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
600

601
    return expireManager.createBuilder(
15✔
602
      value,
603
      VERSION_DEFAULT,
604
      false,
605
      VERSION_DEFAULT,
606
      hardTtlMs,
607
      softTtlMs,
608
      hardTtlExpireAtMs,
609
      softTtlExpireAtMs,
610
      normalHardTtlMs,
611
      normalSoftTtlMs,
612
      state
613
    );
614
  }
615

616
  /**
617
   * Process a cache hit for local hot-key management: if the entry is already
618
   * HOT and more than half its TTL has elapsed, extend its expiry window;
619
   * otherwise promote eligible non-hot entries (NORMAL or COOL-when-all-dead)
620
   * to HOT if the local TopK now considers them hot.
621
   * <p>
622
   * HOT entries that are still within their first half are left untouched —
623
   * no need to re-insert the same state.
624
   *
625
   * @param cacheKey  the key to promote
626
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
627
   * @param val       the extracted value from the cache entry
628
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
629
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
630
   * @return {@code true} if a local promotion or expiry extension occurred
631
   */
632
  private boolean processLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
633
    if (raw instanceof CacheEntry ce) {
6✔
634
      if (ce.getKeyState() == KeyState.HOT && ce.getHardExpireAtMs() != Long.MAX_VALUE) {
9✔
635
        return extendHotKeyExpiryIfNeeded(cacheKey, ce, hardTtlMs, softTtlMs);
7✔
636
      }
637
      if (isPromotableState(ce.getKeyState())) {
5✔
638
        return promoteIfLocalHot(cacheKey, val, ce, hardTtlMs, softTtlMs);
8✔
639
      }
640
    }
641
    return false;
2✔
642
  }
643

644
  /**
645
   * Extend the expiry of a HOT entry if more than half its TTL has elapsed.
646
   *
647
   * @return {@code true} if the entry was extended
648
   */
649
  private boolean extendHotKeyExpiryIfNeeded(String cacheKey, CacheEntry ce, long hardTtlMs, long softTtlMs) {
650
    long remainingTtl = ce.getHardExpireAtMs() - TimeSource.currentTimeMillis();
5✔
651
    long totalTtl = ce.getHardTtlMs();
3✔
652

653
    if (totalTtl > 0 && remainingTtl < totalTtl / 2) {
10!
654
      expireManager.extendExpiry(cacheKey, hardTtlMs, softTtlMs);
6✔
655
      return true;
2✔
656
    }
657
    return false;
2✔
658
  }
659

660
  /**
661
   * Promote a NORMAL or COOL entry to HOT if the local TopK now considers it hot.
662
   *
663
   * @return {@code true} if the entry was promoted
664
   */
665
  private boolean promoteIfLocalHot(String cacheKey, Object val, CacheEntry ce, long hardTtlMs, long softTtlMs) {
666
    if (!hotKeyDetector.contains(cacheKey)) {
5✔
667
      return false;
2✔
668
    }
669
    long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
670
    long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
671

672
    promote(cacheKey, val, ce, hotHard, hotSoft);
7✔
673
    return true;
2✔
674
  }
675

676
  /**
677
   * Atomically promote a cache entry to HOT state in L1, respecting the
678
   * Worker-managed guard (entries in HOT/COOL state from the Worker are
679
   * left untouched).
680
   *
681
   * @param cacheKey the key to promote
682
   * @param val      the underlying cached value
683
   * @param ce       the existing {@link CacheEntry} from which metadata
684
   *                 (version, normal TTLs) is preserved
685
   * @param hotHard  the hot-entry hard TTL
686
   * @param hotSoft  the hot-entry soft TTL
687
   */
688
  private void promote(String cacheKey, Object val, CacheEntry ce, long hotHard, long hotSoft) {
689
    caffeineCache
2✔
690
      .asMap()
8✔
691
      .compute(cacheKey, (k, existing) -> {
2✔
692
        if (existing instanceof CacheEntry entry) {
6!
693
          if (!isPromotableState(entry.getKeyState())) {
5!
694
            return existing;
×
695
          }
696

697
          return expireManager.applyTtl(entry, hotHard, hotSoft).toBuilder().keyState(KeyState.HOT).build();
11✔
698
        }
699

700
        return expireManager.createBuilder(
×
701
          val,
702
          ce.getDataVersion(),
×
703
          ce.isVersionDegraded(),
×
704
          ce.getDecisionVersion(),
×
705
          hotHard,
706
          hotSoft,
707
          ce.getNormalHardTtlMs(),
×
708
          ce.getNormalSoftTtlMs(),
×
709
          KeyState.HOT
710
        );
711
      });
712
  }
1✔
713

714
  /**
715
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
716
   * so they remove their local copy and re-fetch on next {@link #get}.
717
   * The next local {@link #get} will re-fetch from the reader.
718
   *
719
   * @param cacheKey the key to invalidate
720
   */
721
  public void invalidate(String cacheKey) {
722
    if (invalidCacheKey(cacheKey)) {
3✔
723
      log.debug("invalidate: invalid cacheKey");
3✔
724
      return;
1✔
725
    }
726

727
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
728
      caffeineCache.invalidate(cacheKey);
4✔
729
      bumpAndInvalidate(cacheKey);
3✔
730
    });
1✔
731
  }
1✔
732

733
  /**
734
   * Increment the data version asynchronously and broadcast an INVALIDATE
735
   * message to all peer instances.
736
   * <p>Extracted from {@link #invalidate} and {@link #putBeforeInvalidate} to
737
   * eliminate the duplicated async-broadcast block.
738
   */
739
  private void bumpAndInvalidate(String cacheKey) {
740
    try {
741
      hotKeyExecutor.execute(() -> {
6✔
742
        try {
743
          var vr = versionController.nextVersion(cacheKey);
5✔
744
          dispatcher.broadcast(cacheKey, SyncMessage.TYPE_INVALIDATE, vr.dataVersion(), vr.degraded());
9✔
UNCOV
745
        } catch (Exception ex) {
×
746
          log.error("invalidate async broadcast failed for key={}", cacheKey, ex);
×
747
        }
1✔
748
      });
1✔
749
    } catch (RejectedExecutionException ree) {
×
750
      log.warn(
×
751
        "invalidate executor rejected for key={}, peer invalidation deferred to next cycle: {}",
752
        cacheKey,
753
        ree.getMessage()
×
754
      );
755
    }
1✔
756
  }
1✔
757

758
  /**
759
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
760
   * Invalid keys (null or blank) are silently skipped.
761
   *
762
   * @param cacheKeys the keys to invalidate
763
   */
764
  public void invalidateAll(Collection<String> cacheKeys) {
765
    List<String> validKeys = cacheKeys
1✔
766
      .stream()
2✔
767
      .filter(k -> !invalidCacheKey(k))
8✔
768
      .toList();
2✔
769
    if (validKeys.isEmpty()) {
3✔
770
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
771
      return;
1✔
772
    }
773

774
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
775
      caffeineCache.invalidateAll(validKeys);
4✔
776
      dispatcher.broadcast(validKeys, SyncMessage.TYPE_INVALIDATE_ALL);
5✔
777
    });
1✔
778
  }
1✔
779

780
  /**
781
   * Evict keys from the local cache only, without broadcasting to other
782
   * instances and without bumping version numbers.
783
   *
784
   * <p>Useful for emergency local cleanup, testing, or when a module is
785
   * taken offline and only the current node needs to be cleared.
786
   *
787
   * @param cacheKeys the keys to evict locally
788
   */
789
  public void evictLocal(Collection<String> cacheKeys) {
790
    List<String> validKeys = cacheKeys
1✔
791
      .stream()
2✔
792
      .filter(k -> !invalidCacheKey(k))
8✔
793
      .toList();
2✔
794
    if (validKeys.isEmpty()) {
3✔
795
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
796
      return;
1✔
797
    }
798
    caffeineCache.invalidateAll(validKeys);
4✔
799
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
800
  }
1✔
801

802
  /**
803
   * Write-through: execute the writer, then update L1 and broadcast.
804
   * Uses effective hard/soft TTL from configuration.
805
   *
806
   * @param cacheKey the key to write
807
   * @param value    the value to cache
808
   * @param writer   the data-source mutation to execute before caching
809
   * @param <T>      the value type
810
   * @throws HotKeyBlockedException when the key matches a blacklist rule
811
   */
812
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
813
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
814
  }
1✔
815

816
  /**
817
   * Write-through with explicit TTL overrides.
818
   * Pass 0 to use the configured default for that TTL type.
819
   *
820
   * @param cacheKey  the key to write
821
   * @param value     the value to cache
822
   * @param writer    the data-source mutation to execute before caching
823
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
824
   * @param softTtlMs soft TTL override (0 = use configured default)
825
   * @param <T>       the value type
826
   */
827
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
828
    if (preGuard(cacheKey) == null) {
4✔
829
      return;
1✔
830
    }
831

832
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
833

834
    TransactionSupport.runAsyncAfterCommit(
11✔
835
      () -> {
836
        try {
837
          writer.run();
2✔
838
          writerOk.compareAndSet(false, true);
5✔
839
        } catch (Exception e) {
×
840
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
841
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
842
          return;
×
843
        }
1✔
844

845
        long effectiveHardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
846
        long effectiveSoftTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
847

848
        try {
849
          var vr = versionController.nextVersion(cacheKey);
5✔
850

851
          caffeineCache
2✔
852
            .asMap()
8✔
853
            .compute(cacheKey, (k, existing) ->
2✔
854
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
855
            );
856

857
          dispatcher.broadcast(cacheKey, SyncMessage.TYPE_REFRESH, vr.dataVersion(), vr.degraded());
9✔
UNCOV
858
        } catch (RejectedExecutionException ree) {
×
859
          // hotKeyExecutor saturated mid-body (nextVersion/Redis did run, but
860
          // we cannot distinguish here — VersionController.nextVersion already
861
          // has its own Redis-degraded fallback path). Cache and broadcast are
862
          // skipped; the next read or the next periodic Worker cycle will
863
          // reconcile. Logged at ERROR so operators can detect silent drops.
864
          log.error(
×
865
            "putThrough executor rejected mid-flight for key={} (local cache NOT updated, broadcast NOT sent)",
866
            cacheKey,
867
            ree
868
          );
869
          throw ree; // CompletableFuture.exceptionally downstream decides policy
×
870
        } catch (Exception e) {
×
871
          // Redis-relayed exception or unexpected error. The local L1 is still
872
          // updated using a degraded version so the cache remains coherent with
873
          // the mutation that already succeeded on the writer.
874
          log.error("putThrough cache/broadcast failed for key={} (applying degraded local update)", cacheKey, e);
×
875
          var vr = versionController.fallbackVersion();
×
876

877
          caffeineCache
×
878
            .asMap()
×
879
            .compute(cacheKey, (k, existing) ->
×
880
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
881
            );
NEW
882
          dispatcher.broadcast(cacheKey, SyncMessage.TYPE_REFRESH, vr.dataVersion(), vr.degraded());
×
883
        }
1✔
884
      },
1✔
885
      hotKeyExecutor
886
    );
887
  }
1✔
888

889
  /**
890
   * Builds a {@link CacheEntry} for a write-through operation, preserving
891
   * Worker-managed state and version information from the existing entry.
892
   *
893
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
894
   * @param value             the new value to cache
895
   * @param vr                the version result from the version controller
896
   * @param effectiveHardTtl  the effective hard TTL to use
897
   * @param effectiveSoftTtl  the effective soft TTL to use
898
   * @param <T>               the value type
899
   * @return a new {@link CacheEntry} with the supplied value and metadata
900
   */
901
  @SuppressWarnings("all")
902
  private <T> CacheEntry buildPutThroughEntry(
903
    Object existing,
904
    T value,
905
    VersionController.VersionResult vr,
906
    long effectiveHardTtl,
907
    long effectiveSoftTtl
908
  ) {
909
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
910
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
911
      return ce;
2✔
912
    }
913

914
    boolean isWorkerManaged = isWorkerManaged(existing);
3✔
915

916
    long decisionVersion = 0L;
2✔
917
    String decisionNodeId = null;
2✔
918
    long decisionEpoch = 0L;
2✔
919
    long normalHardTtl = effectiveHardTtl;
2✔
920
    long normalSoftTtl = effectiveSoftTtl;
2✔
921

922
    if (existing instanceof CacheEntry entry) {
6✔
923
      decisionVersion = entry.getDecisionVersion();
3✔
924
      decisionNodeId = entry.getDecisionNodeId();
3✔
925
      decisionEpoch = entry.getDecisionEpoch();
3✔
926
      if (isWorkerManaged) {
2!
927
        normalHardTtl = entry.getNormalHardTtlMs();
3✔
928
        normalSoftTtl = entry.getNormalSoftTtlMs();
3✔
929
      }
930
    }
931

932
    long hardTtl = effectiveHardTtl;
2✔
933
    long softTtl = effectiveSoftTtl;
2✔
934
    if (state == KeyState.HOT) {
3✔
935
      hardTtl = expireManager.resolveEffectiveHotHard(hardTtl);
5✔
936
      softTtl = expireManager.resolveEffectiveHotSoft(softTtl);
5✔
937
    }
938

939
    Object wrapValue = value != null ? value : NullValue.INSTANCE;
6✔
940
    return expireManager.createBuilder(
6✔
941
      wrapValue,
942
      vr.dataVersion(),
2✔
943
      vr.degraded(),
9✔
944
      decisionVersion,
945
      decisionNodeId,
946
      decisionEpoch,
947
      hardTtl,
948
      softTtl,
949
      normalHardTtl,
950
      normalSoftTtl,
951
      state
952
    );
953
  }
954

955
  /**
956
   * Write a value directly into the local L1 cache without version bump,
957
   * without broadcast, without hot-key detection, and without reporting.
958
   * <p>
959
   * Existing entry metadata is preserved.  If no entry exists, a fresh
960
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
961
   * <p>
962
   * Pass {@code 0} for either TTL to use the configured default.
963
   *
964
   * @param cacheKey  the key to store
965
   * @param value     the value to cache
966
   * @param hardTtlMs hard TTL override (0 = use configured default)
967
   * @param softTtlMs soft TTL override (0 = use configured default)
968
   */
969
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
970
    if (preGuard(cacheKey) == null) return;
5✔
971

972
    long hardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
973
    long softTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
974

975
    caffeineCache
2✔
976
      .asMap()
7✔
977
      .compute(cacheKey, (k, existing) -> {
2✔
978
        if (existing instanceof CacheEntry ce) {
6✔
979
          return expireManager.applyTtl(expireManager.replaceEntryValue(ce, value), hardTtl, softTtl);
11✔
980
        }
981
        return expireManager.createBuilder(
13✔
982
          value,
983
          VERSION_DEFAULT,
984
          false,
985
          0L,
986
          hardTtl,
987
          softTtl,
988
          hardTtl,
989
          softTtl,
990
          KeyState.NORMAL
991
        );
992
      });
993
  }
1✔
994

995
  /**
996
   * Execute a mutation, then invalidate L1 and broadcast.
997
   * Next {@link #get} will re-fetch from the reader.
998
   *
999
   * @param cacheKey the key to invalidate after mutation
1000
   * @param mutation the mutation to execute
1001
   */
1002
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
1003
    if (preGuard(cacheKey) == null) return;
5✔
1004

1005
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
1006
      try {
1007
        mutation.run();
2✔
1008
      } catch (Exception e) {
1✔
1009
        log.error("putBeforeInvalidate mutation failed, skip invalidate and broadcast: {}", cacheKey, e);
5✔
1010
        return;
1✔
1011
      }
1✔
1012
      caffeineCache.invalidate(cacheKey);
4✔
1013
      bumpAndInvalidate(cacheKey);
3✔
1014
    });
1✔
1015
  }
1✔
1016

1017
  /**
1018
   * Add or remove a rule atomically within a transaction boundary.
1019
   * <p>Extracted from the four public rule methods to eliminate the
1020
   * identical validation-and-commit pattern.
1021
   *
1022
   * @param add {@code true} to add the rule, {@code false} to remove it
1023
   */
1024
  private void modifyRule(String cacheKey, RuleAction action, boolean add) {
1025
    if (invalidCacheKey(cacheKey)) {
3✔
1026
      log.debug("modifyRule: invalid cacheKey '{}'", cacheKey);
4✔
1027
      return;
1✔
1028
    }
1029
    TransactionSupport.runNowOrAfterCommit(() -> {
6✔
1030
      if (add) ruleMatcher.addRule(RuleMatcher.of(cacheKey, action));
9✔
1031
      else ruleMatcher.removeRule(cacheKey, action);
6✔
1032
      log.info("{} {}", add ? "Added" : "Removed", action);
9✔
1033
    });
1✔
1034
  }
1✔
1035

1036
  public void addBlacklist(String cacheKey) {
1037
    modifyRule(cacheKey, RuleAction.BLOCK, true);
5✔
1038
  }
1✔
1039

1040
  public void addWhitelist(String cacheKey) {
1041
    modifyRule(cacheKey, RuleAction.ALLOW_NO_REPORT, true);
5✔
1042
  }
1✔
1043

1044
  public void unBlacklist(String cacheKey) {
1045
    modifyRule(cacheKey, RuleAction.BLOCK, false);
5✔
1046
  }
1✔
1047

1048
  public void unWhitelist(String cacheKey) {
1049
    modifyRule(cacheKey, RuleAction.ALLOW_NO_REPORT, false);
5✔
1050
  }
1✔
1051

1052
  /**
1053
   * Evaluate all rules against the given key and return the first matching action.
1054
   *
1055
   * @param cacheKey the key to evaluate
1056
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
1057
   */
1058
  public RuleAction evaluateRule(String cacheKey) {
1059
    return ruleMatcher.evaluateRule(cacheKey);
5✔
1060
  }
1061

1062
  /**
1063
   * Return a snapshot of all current rules in evaluation order.
1064
   *
1065
   * @return list of rules (immutable snapshot)
1066
   */
1067
  public List<Rule> getAllRules() {
1068
    return ruleMatcher.getAllRules();
4✔
1069
  }
1070

1071
  /**
1072
   * Remove all blacklist and whitelist rules.
1073
   */
1074
  public void clearAllRules() {
1075
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1076
  }
1✔
1077

1078
  /**
1079
   * Broadcast all local rules to peer instances via the sync exchange.
1080
   * Useful for initial synchronization when a new instance joins the cluster.
1081
   */
1082
  public void broadcastAllLocalRulesManually() {
1083
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1084
  }
1✔
1085

1086
  /**
1087
   * Estimated number of entries currently in the L1 cache.
1088
   *
1089
   * @return best-effort estimate of the current entry count
1090
   */
1091
  public long estimatedSize() {
1092
    return caffeineCache.estimatedSize();
4✔
1093
  }
1094

1095
  /**
1096
   * Return a snapshot of basic L1 cache statistics.
1097
   * <p>
1098
   * Hit/miss/eviction counters are populated only when Caffeine's
1099
   * {@code recordStats()} is enabled.  {@code estimatedSizeOfKeysCount} is always
1100
   * available.
1101
   *
1102
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
1103
   *         if stats recording is not enabled
1104
   */
1105
  public HotKeyCacheStats stats() {
1106
    CacheStats cs = caffeineCache.stats();
4✔
1107
    return new HotKeyCacheStats(
4✔
1108
      cs.hitCount(),
2✔
1109
      cs.missCount(),
2✔
1110
      cs.hitRate(),
2✔
1111
      cs.evictionCount(),
3✔
1112
      caffeineCache.estimatedSize()
2✔
1113
    );
1114
  }
1115

1116
  /**
1117
   * Check whether the given key is blacklisted.
1118
   *
1119
   * @param cacheKey the key to check
1120
   * @return {@code true} if a blacklist rule matches the key
1121
   */
1122

1123
  public boolean isBlacklisted(String cacheKey) {
1124
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1125
  }
1126

1127
  /**
1128
   * Check whether the given key is whitelisted (skips Worker reporting).
1129
   *
1130
   * @param cacheKey the key to check
1131
   * @return {@code true} if a whitelist rule matches the key
1132
   */
1133
  public boolean isWhitelisted(String cacheKey) {
1134
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1135
  }
1136

1137
  /**
1138
   * Invalidate all entries from the L1 cache without broadcasting.
1139
   * <p>
1140
   * This is an emergency flush — all cached values are removed immediately.
1141
   * No cross-instance sync messages are sent.
1142
   */
1143
  public void invalidateAllLocal() {
1144
    caffeineCache.invalidateAll();
3✔
1145
  }
1✔
1146

1147
  /**
1148
   * Return the underlying Caffeine cache for direct access.
1149
   *
1150
   * <p>This provides access to Caffeine-specific operations such as
1151
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
1152
   * caution — bypassing the HotKey orchestration layer (version tracking,
1153
   * broadcast, expiry management) can lead to inconsistent state.
1154
   *
1155
   * @return the raw Caffeine {@link Cache} instance
1156
   */
1157
  public Cache<String, Object> getLocalCache() {
1158
    return caffeineCache;
3✔
1159
  }
1160

1161
  /**
1162
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1163
   * <p>
1164
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1165
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1166
   * transparently returned as {@code null} to callers.
1167
   *
1168
   * @param stored the raw value from a {@link CacheEntry}
1169
   * @return {@code null} if the sentinel, otherwise the original value
1170
   */
1171
  @Nullable
1172
  private Object unwrapValue(@Nullable Object stored) {
1173
    try {
1174
      Object val = compressor.unwrap(stored);
5✔
1175
      return val == NullValue.INSTANCE ? null : val;
7✔
1176
    } catch (IOException e) {
×
1177
      log.warn("Failed to decompress cache value", e);
×
1178
      return null;
×
1179
    }
1180
  }
1181
}
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