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

Hyshmily / hotkey / 28845108212

07 Jul 2026 06:00AM UTC coverage: 90.477% (+0.9%) from 89.623%
28845108212

push

github

Hyshmily
fix and perf: simplified the code and promote the Weigher accuracy,performance1

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

1312 of 1516 branches covered (86.54%)

Branch coverage included in aggregate %.

102 of 121 new or added lines in 5 files covered. (84.3%)

2 existing lines in 2 files now uncovered.

3714 of 4039 relevant lines covered (91.95%)

4.24 hits per line

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

86.37
/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.constants.HotKeyConstants;
31
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
32
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
33
import io.github.hyshmily.hotkey.model.CacheEntry;
34
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
35
import io.github.hyshmily.hotkey.model.KeyState;
36
import io.github.hyshmily.hotkey.reporting.KeyReporter;
37
import io.github.hyshmily.hotkey.rule.Rule;
38
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
39
import io.github.hyshmily.hotkey.rule.RuleMatcher;
40
import io.github.hyshmily.hotkey.sharding.HealthView;
41
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
42
import io.github.hyshmily.hotkey.util.TimeSource;
43
import io.github.hyshmily.hotkey.util.version.VersionController;
44
import io.github.hyshmily.hotkey.util.version.VersionGuard;
45
import jakarta.annotation.Nullable;
46
import java.io.IOException;
47
import java.util.Collection;
48
import java.util.List;
49
import java.util.Optional;
50
import java.util.concurrent.Executor;
51
import java.util.concurrent.RejectedExecutionException;
52
import java.util.concurrent.TimeUnit;
53
import java.util.concurrent.atomic.AtomicBoolean;
54
import java.util.function.Supplier;
55
import lombok.Builder;
56
import lombok.RequiredArgsConstructor;
57
import lombok.extern.slf4j.Slf4j;
58

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

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

85
  /** Optional publisher for cross-instance cache synchronization. */
86
  @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
87
  private final Optional<CacheSyncPublisher> cacheSyncPublisher;
88

89
  /** Optional reporter for app-to-Worker hot key reporting. */
90
  @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
91
  private final Optional<KeyReporter> hotKeyReporter;
92

93
  /** Matches cache keys against blacklist/whitelist rules. */
94
  private final RuleMatcher ruleMatcher;
95
  /** Manages data version generation for mutation ordering. */
96
  private final VersionController versionController;
97

98
  /** HotKey configuration properties (TTL overrides, null-value TTL). */
99
  private final HotKeyProperties hotKeyProperties;
100

101
  /** Cached view of Worker cluster health, used for COOL promotion decisions. */
102
  private final HealthView healthView;
103

104
  /** Compressor for L1 cache values. */
105
  private final CacheCompressor compressor;
106

107
  /** Log message constant when no sync publisher is available. */
108
  private static final String NO_SYNC_PUBLISHER = HotKeyConstants.NO_SYNC_PUBLISHER;
109

110
  @Builder
111
  static class GuardReport {
112

113
    RuleAction action;
114
    boolean isSkipReport;
115
  }
116

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

136
  /**
137
   * Secondary BLOCK check (TOCTOU guard for paths where the key was already validated).
138
   *
139
   * @param cacheKey the cache key to check
140
   * @throws HotKeyBlockedException when the key matches a blocklist rule
141
   */
142
  private void guardBlocked(String cacheKey) {
143
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
NEW
144
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
145
    }
146
  }
1✔
147

148
  /**
149
   * Check whether an existing cache entry is managed by the Worker (HOT or COOL).
150
   * Worker-managed entries preserve their original normal TTLs through writes.
151
   *
152
   * @param existing the existing cache entry (maybe {@code null} or a raw value)
153
   * @return {@code true} if the entry is a {@link CacheEntry} with state HOT or COOL
154
   */
155
  private static boolean isWorkerManaged(Object existing) {
156
    return (
1✔
157
      existing instanceof CacheEntry entry &&
7✔
158
      (entry.getKeyState() == KeyState.HOT || entry.getKeyState() == KeyState.COOL)
10✔
159
    );
160
  }
161

162
  /**
163
   * Check whether a key is currently tracked as a local hot key in L1.
164
   *
165
   * @param cacheKey the key to inspect
166
   * @return {@code true} if the key exists in L1 with {@link KeyState#HOT}
167
   */
168
  public boolean isLocalHotKey(String cacheKey) {
169
    if (invalidCacheKey(cacheKey)) {
3✔
170
      log.debug("isLocalHotKey: invalid cacheKey");
3✔
171
      return false;
2✔
172
    }
173
    Object entry = caffeineCache.getIfPresent(cacheKey);
5✔
174
    return isHotInCache(entry) || hotKeyDetector.contains(cacheKey);
13!
175
  }
176

177
  /**
178
   * Check whether an entry in the local cache is a non-expired HOT entry.
179
   *
180
   * @param entry the raw value from Caffeine (may be {@link CacheEntry} or {@code null})
181
   * @return {@code true} if the entry is a valid, non-expired HOT
182
   */
183
  private boolean isHotInCache(Object entry) {
184
    if (entry instanceof CacheEntry ce && !expireManager.isLogicallyExpired(ce)) {
11✔
185
      return KeyState.HOT == ce.getKeyState();
8✔
186
    }
187
    return false;
2✔
188
  }
189

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

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

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

250
  /**
251
   * Get a value from L1 or load it via the reader.
252
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
253
   *
254
   * @param cacheKey the key to retrieve
255
   * @param reader   the value supplier for cache misses
256
   * @param <T>      the value type
257
   * @return an {@link Optional} containing the cached or loaded value
258
   * @throws HotKeyBlockedException when the key matches a blacklist rule
259
   */
260
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
261
    return get(cacheKey, reader, 0L, 0L);
7✔
262
  }
263

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

281
    boolean skipReport = g.isSkipReport;
3✔
282

283
    return (Optional<T>) withErrorHandling(cacheKey, () ->
11✔
284
      Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
285
        .flatMap(raw -> handleCacheHit(cacheKey, raw, hardTtlMs, softTtlMs, skipReport))
16✔
286
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport))
9✔
287
    );
288
  }
289

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

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

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

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

355
    boolean skipReport = g.isSkipReport;
3✔
356

357
    if (!expireManager.isSoftExpireEnabled()) {
4✔
358
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
359
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
360
    }
361
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
362
    return withErrorHandling(cacheKey, () ->
12✔
363
      Optional.ofNullable(raw)
10✔
364
        .flatMap(v -> handleSoftExpireHit(cacheKey, v, reader, hardTtlMs, softTtlMs, skipReport))
17✔
365
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport))
9✔
366
    );
367
  }
368

369
  private <T> Optional<T> handleSoftExpireHit(
370
    String cacheKey,
371
    Object raw,
372
    Supplier<T> reader,
373
    long hardTtlMs,
374
    long softTtlMs,
375
    boolean skipReport
376
  ) {
377
    if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, raw)) {
6✔
378
      return Optional.empty();
2✔
379
    }
380
    @SuppressWarnings("unchecked")
381
    T cached = raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw;
13✔
382
    triggerSoftExpireRefresh(cacheKey, raw, reader, softTtlMs);
6✔
383
    return process(cacheKey, raw, cached, hardTtlMs, softTtlMs, skipReport);
9✔
384
  }
385

386
  /**
387
   * Trigger a background refresh if the raw value is a worker-managed entry
388
   * and its soft TTL has expired.
389
   *
390
   * @param cacheKey  the cache key
391
   * @param raw       the raw value from Caffeine (may be {@link CacheEntry} or bare)
392
   * @param reader    the value supplier for the refresh
393
   * @param softTtlMs per-call soft TTL override (0 = use configured default)
394
   */
395
  private void triggerSoftExpireRefresh(String cacheKey, Object raw, Supplier<?> reader, long softTtlMs) {
396
    if (!isWorkerManaged(raw)) return;
4✔
397

398
    CacheEntry ce = (CacheEntry) raw;
3✔
399
    if (!expireManager.isSoftExpired(ce)) return;
6✔
400

401
    long effectiveSoft = computeSoftTtlForRefresh(softTtlMs, ce);
5✔
402
    expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
403
  }
1✔
404

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

421
  /**
422
   * Process a cache hit: trigger local hot-key promotion/renewal and report
423
   * to Worker, then re-check logical expiry (TOCTOU guard) after side effects.
424
   * <p>Extracted from {@link #get} and {@link #getWithSoftExpire} to eliminate
425
   * code duplication.
426
   *
427
   * @param cacheKey  the cache key
428
   * @param raw       the raw value from Caffeine (may be {@link CacheEntry} or bare)
429
   * @param cached    the unwrapped cached value
430
   * @param hardTtlMs hard TTL override
431
   * @param softTtlMs soft TTL override
432
   * @param skipReport if {@code true}, skip app-to-Worker reporting
433
   * @param <T>       the value type
434
   * @return an {@link Optional} containing the cached value, or empty if logically expired
435
   */
436
  private <T> Optional<T> process(
437
    String cacheKey,
438
    Object raw,
439
    T cached,
440
    long hardTtlMs,
441
    long softTtlMs,
442
    boolean skipReport
443
  ) {
444
    boolean wasProcessed = processLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
445
    if (!skipReport) {
2✔
446
      hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
447
    }
448

449
    // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
450
    if (wasProcessed || !skipReport) {
4✔
451
      Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
452
      if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
6!
453
        return Optional.empty();
×
454
      }
455
    }
456

457
    return Optional.ofNullable(cached);
3✔
458
  }
459

460
  /**
461
   * Load via SingleFlight, detect hot key, cache with HOT or NORMAL TTL, and return value.
462
   *
463
   * @param cacheKey   the key to load
464
   * @param reader     the value supplier
465
   * @param hardTtlMs  hard TTL override (0 = use configured default)
466
   * @param softTtlMs  soft TTL override (0 = use configured default)
467
   * @param skipReport if {@code true}, skip reporting to Worker
468
   */
469
  private <T> Optional<T> loadAndCache(
470
    String cacheKey,
471
    Supplier<T> reader,
472
    long hardTtlMs,
473
    long softTtlMs,
474
    boolean skipReport
475
  ) {
476
    guardBlocked(cacheKey);
3✔
477
    Optional<T> result = singleFlight.load(cacheKey, reader);
6✔
478

479
    if (result.isEmpty()) {
3!
NEW
480
      return mapEmpty(cacheKey);
×
481
    }
482
    T value = result.get();
3✔
483
    return Optional.of(mapLoaded(cacheKey, value, hardTtlMs, softTtlMs, skipReport));
9✔
484
  }
485

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

502
      if (stale != null) {
×
503
        T val = stale instanceof CacheEntry ce ? (T) unwrapValue(ce.getValue()) : (T) stale;
×
504
        log.debug("CB open, returning stale entry for key={}", cacheKey);
×
505
        return Optional.ofNullable(val);
×
506
      }
507
    }
NEW
508
    putNullValueEntry(cacheKey);
×
NEW
509
    return Optional.empty();
×
510
  }
511

512
  /**
513
   * Cache a {@link NullValue} sentinel in L1 with a short TTL so that
514
   * subsequent reads return empty without hitting the reader.
515
   *
516
   * @param cacheKey the key to store the null sentinel for
517
   */
518
  private void putNullValueEntry(String cacheKey) {
519
    long nullTtlMs = TimeUnit.SECONDS.toMillis(hotKeyProperties.getNullValueTtlSeconds());
×
520
    long nullExpireAtMs = expireManager.computeNullExpireAt(nullTtlMs);
×
UNCOV
521
    caffeineCache.put(
×
522
      cacheKey,
523
      expireManager.createBuilder(
×
524
        NullValue.INSTANCE,
525
        VERSION_DEFAULT,
526
        false,
527
        0L,
528
        nullTtlMs,
529
        0L,
530
        nullExpireAtMs,
531
        0L,
532
        0L,
533
        0L,
534
        KeyState.NORMAL
535
      )
536
    );
537
  }
×
538

539
  /**
540
   * Process a successfully loaded value: re-check blacklist, detect hot key via
541
   * HeavyKeeper, store in L1 with HOT or NORMAL TTL, and optionally report to Worker.
542
   *
543
   * @param cacheKey   the key to cache
544
   * @param value      the loaded value (must not be null at this point)
545
   * @param hardTtlMs  hard TTL override (0 = use configured default)
546
   * @param softTtlMs  soft TTL override (0 = use configured default)
547
   * @param skipReport if {@code true}, skip reporting to Worker
548
   * @param <T>        the value type
549
   * @return the loaded value (unchanged)
550
   */
551
  private <T> T mapLoaded(String cacheKey, T value, long hardTtlMs, long softTtlMs, boolean skipReport) {
552
    guardBlocked(cacheKey);
3✔
553

554
    long effectiveHard = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
555
    long effectiveSoft = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
556

557
    hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
558
    if (hotKeyDetector.contains(cacheKey)) {
5✔
559
      long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
560
      long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
561

562
      storeCacheEntry(cacheKey, value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
563
      reportingIfAvailable(skipReport, cacheKey);
4✔
564
      log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
565
    } else {
1✔
566
      storeCacheEntry(cacheKey, value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
567
      reportingIfAvailable(skipReport, cacheKey);
4✔
568
      log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
569
    }
570
    return value;
2✔
571
  }
572

573
  /**
574
   * Atomically insert or update the entry in L1 via {@code compute}, skipping
575
   * the update if the existing entry is Worker-managed (HOT or COOL).
576
   *
577
   * @param cacheKey      the key to store
578
   * @param value         the value to cache
579
   * @param hardTtlMs     the hard TTL for this entry
580
   * @param softTtlMs     the soft TTL for this entry
581
   * @param state         the key state to assign (HOT or NORMAL)
582
   * @param normalHardTtl the normal (non-hot) hard TTL to preserve for demotion
583
   * @param normalSoftTtl the normal (non-hot) soft TTL to preserve for demotion
584
   */
585
  private void storeCacheEntry(
586
    String cacheKey,
587
    Object value,
588
    long hardTtlMs,
589
    long softTtlMs,
590
    KeyState state,
591
    long normalHardTtl,
592
    long normalSoftTtl
593
  ) {
594
    caffeineCache
2✔
595
      .asMap()
10✔
596
      .compute(cacheKey, (k, existing) -> {
2✔
597
        if (isWorkerManaged(existing)) {
3✔
598
          return existing;
2✔
599
        }
600
        return buildEntry(value, hardTtlMs, softTtlMs, state, normalHardTtl, normalSoftTtl);
9✔
601
      });
602
  }
1✔
603

604
  /**
605
   * Record a key access to the {@link KeyReporter} unless reporting is
606
   * explicitly skipped.
607
   *
608
   * @param skipReport if {@code true}, no-op
609
   * @param cacheKey   the key accessed
610
   */
611
  private void reportingIfAvailable(boolean skipReport, String cacheKey) {
612
    if (!skipReport) {
2✔
613
      hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
614
    }
615
  }
1✔
616

617
  private <T> CacheEntry buildEntry(
618
    T value,
619
    long hardTtlMs,
620
    long softTtlMs,
621
    KeyState state,
622
    long normalHardTtlMs,
623
    long normalSoftTtlMs
624
  ) {
625
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
626
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
627

628
    return expireManager.createBuilder(
15✔
629
      value,
630
      VERSION_DEFAULT,
631
      false,
632
      VERSION_DEFAULT,
633
      hardTtlMs,
634
      softTtlMs,
635
      hardTtlExpireAtMs,
636
      softTtlExpireAtMs,
637
      normalHardTtlMs,
638
      normalSoftTtlMs,
639
      state
640
    );
641
  }
642

643
  /**
644
   * Process a cache hit for local hot-key management: if the entry is already
645
   * HOT and more than half its TTL has elapsed, extend its expiry window;
646
   * otherwise promote eligible non-hot entries (NORMAL or COOL-when-all-dead)
647
   * to HOT if the local TopK now considers them hot.
648
   * <p>
649
   * HOT entries that are still within their first half are left untouched —
650
   * no need to re-insert the same state.
651
   *
652
   * @param cacheKey  the key to promote
653
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
654
   * @param val       the extracted value from the cache entry
655
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
656
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
657
   * @return {@code true} if a local promotion or expiry extension occurred
658
   */
659
  private boolean processLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
660
    if (raw instanceof CacheEntry ce) {
6✔
661
      if (ce.getKeyState() == KeyState.HOT && ce.getHardExpireAtMs() != Long.MAX_VALUE) {
9✔
662
        return extendHotKeyExpiryIfNeeded(cacheKey, ce, hardTtlMs, softTtlMs);
7✔
663
      }
664
      if (isPromotableState(ce.getKeyState())) {
5✔
665
        return promoteIfLocalHot(cacheKey, val, ce, hardTtlMs, softTtlMs);
8✔
666
      }
667
    }
668
    return false;
2✔
669
  }
670

671
  /**
672
   * Extend the expiry of a HOT entry if more than half its TTL has elapsed.
673
   *
674
   * @return {@code true} if the entry was extended
675
   */
676
  private boolean extendHotKeyExpiryIfNeeded(String cacheKey, CacheEntry ce, long hardTtlMs, long softTtlMs) {
677
    long remainingTtl = ce.getHardExpireAtMs() - TimeSource.currentTimeMillis();
5✔
678
    long totalTtl = ce.getHardTtlMs();
3✔
679

680
    if (totalTtl > 0 && remainingTtl < totalTtl / 2) {
10!
681
      expireManager.extendExpiry(cacheKey, hardTtlMs, softTtlMs);
6✔
682
      return true;
2✔
683
    }
684
    return false;
2✔
685
  }
686

687
  /**
688
   * Promote a NORMAL or COOL entry to HOT if the local TopK now considers it hot.
689
   *
690
   * @return {@code true} if the entry was promoted
691
   */
692
  private boolean promoteIfLocalHot(String cacheKey, Object val, CacheEntry ce, long hardTtlMs, long softTtlMs) {
693
    hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
694
    if (!hotKeyDetector.contains(cacheKey)) {
5✔
695
      return false;
2✔
696
    }
697
    long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
698
    long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
699

700
    promote(cacheKey, val, ce, hotHard, hotSoft);
7✔
701
    return true;
2✔
702
  }
703

704
  /**
705
   * Atomically promote a cache entry to HOT state in L1, respecting the
706
   * Worker-managed guard (entries in HOT/COOL state from the Worker are
707
   * left untouched).
708
   *
709
   * @param cacheKey the key to promote
710
   * @param val      the underlying cached value
711
   * @param ce       the existing {@link CacheEntry} from which metadata
712
   *                 (version, normal TTLs) is preserved
713
   * @param hotHard  the hot-entry hard TTL
714
   * @param hotSoft  the hot-entry soft TTL
715
   */
716
  private void promote(String cacheKey, Object val, CacheEntry ce, long hotHard, long hotSoft) {
717
    caffeineCache
2✔
718
      .asMap()
8✔
719
      .compute(cacheKey, (k, existing) -> {
2✔
720
        if (existing instanceof CacheEntry entry) {
6!
721
          if (!isPromotableState(entry.getKeyState())) {
5!
722
            return existing;
×
723
          }
724

725
          return expireManager.applyTtl(entry, hotHard, hotSoft).toBuilder().keyState(KeyState.HOT).build();
11✔
726
        }
727

728
        return expireManager.createBuilder(
×
729
          val,
730
          ce.getDataVersion(),
×
731
          ce.isVersionDegraded(),
×
732
          ce.getDecisionVersion(),
×
733
          hotHard,
734
          hotSoft,
735
          ce.getNormalHardTtlMs(),
×
736
          ce.getNormalSoftTtlMs(),
×
737
          KeyState.HOT
738
        );
739
      });
740
  }
1✔
741

742
  /**
743
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
744
   * so they remove their local copy and re-fetch on next {@link #get}.
745
   * The next local {@link #get} will re-fetch from the reader.
746
   *
747
   * @param cacheKey the key to invalidate
748
   */
749
  public void invalidate(String cacheKey) {
750
    if (invalidCacheKey(cacheKey)) {
3✔
751
      log.debug("invalidate: invalid cacheKey");
3✔
752
      return;
1✔
753
    }
754

755
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
756
      caffeineCache.invalidate(cacheKey);
4✔
757
      bumpAndInvalidate(cacheKey);
3✔
758
    });
1✔
759
  }
1✔
760

761
  /**
762
   * Increment the data version asynchronously and broadcast an INVALIDATE
763
   * message to all peer instances.
764
   * <p>Extracted from {@link #invalidate} and {@link #putBeforeInvalidate} to
765
   * eliminate the duplicated async-broadcast block.
766
   */
767
  private void bumpAndInvalidate(String cacheKey) {
768
    try {
769
      hotKeyExecutor.execute(() -> {
6✔
770
        try {
771
          var vr = versionController.nextVersion(cacheKey);
5✔
772
          cacheSyncPublisher.ifPresentOrElse(
7✔
773
            p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
774
            () -> log.debug("invalidate async: " + NO_SYNC_PUBLISHER)
4✔
775
          );
NEW
776
        } catch (Exception ex) {
×
NEW
777
          log.error("invalidate async broadcast failed for key={}", cacheKey, ex);
×
778
        }
1✔
779
      });
1✔
NEW
780
    } catch (RejectedExecutionException ree) {
×
NEW
781
      log.warn(
×
782
        "invalidate executor rejected for key={}, peer invalidation deferred to next cycle: {}",
783
        cacheKey,
NEW
784
        ree.getMessage()
×
785
      );
786
    }
1✔
787
  }
1✔
788

789
  /**
790
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
791
   * Invalid keys (null or blank) are silently skipped.
792
   *
793
   * @param cacheKeys the keys to invalidate
794
   */
795
  public void invalidateAll(Collection<String> cacheKeys) {
796
    List<String> validKeys = cacheKeys
1✔
797
      .stream()
2✔
798
      .filter(k -> !invalidCacheKey(k))
8✔
799
      .toList();
2✔
800
    if (validKeys.isEmpty()) {
3✔
801
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
802
      return;
1✔
803
    }
804

805
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
806
      caffeineCache.invalidateAll(validKeys);
4✔
807
      cacheSyncPublisher.ifPresentOrElse(
7✔
808
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
809
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
810
      );
811
    });
1✔
812
  }
1✔
813

814
  /**
815
   * Evict keys from the local cache only, without broadcasting to other
816
   * instances and without bumping version numbers.
817
   *
818
   * <p>Useful for emergency local cleanup, testing, or when a module is
819
   * taken offline and only the current node needs to be cleared.
820
   *
821
   * @param cacheKeys the keys to evict locally
822
   */
823
  public void evictLocal(Collection<String> cacheKeys) {
824
    List<String> validKeys = cacheKeys
1✔
825
      .stream()
2✔
826
      .filter(k -> !invalidCacheKey(k))
8✔
827
      .toList();
2✔
828
    if (validKeys.isEmpty()) {
3✔
829
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
830
      return;
1✔
831
    }
832
    caffeineCache.invalidateAll(validKeys);
4✔
833
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
834
  }
1✔
835

836
  /**
837
   * Write-through: execute the writer, then update L1 and broadcast.
838
   * Uses effective hard/soft TTL from configuration.
839
   *
840
   * @param cacheKey the key to write
841
   * @param value    the value to cache
842
   * @param writer   the data-source mutation to execute before caching
843
   * @param <T>      the value type
844
   * @throws HotKeyBlockedException when the key matches a blacklist rule
845
   */
846
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
847
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
848
  }
1✔
849

850
  /**
851
   * Write-through with explicit TTL overrides.
852
   * Pass 0 to use the configured default for that TTL type.
853
   *
854
   * @param cacheKey  the key to write
855
   * @param value     the value to cache
856
   * @param writer    the data-source mutation to execute before caching
857
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
858
   * @param softTtlMs soft TTL override (0 = use configured default)
859
   * @param <T>       the value type
860
   */
861
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
862
    if (preGuard(cacheKey) == null) {
4✔
863
      return;
1✔
864
    }
865

866
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
867

868
    TransactionSupport.runAsyncAfterCommit(
11✔
869
      () -> {
870
        try {
871
          writer.run();
2✔
872
          writerOk.compareAndSet(false, true);
5✔
873
        } catch (Exception e) {
×
874
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
875
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
876
          return;
×
877
        }
1✔
878

879
        long effectiveHardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
880
        long effectiveSoftTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
881

882
        try {
883
          var vr = versionController.nextVersion(cacheKey);
5✔
884

885
          caffeineCache
2✔
886
            .asMap()
8✔
887
            .compute(cacheKey, (k, existing) ->
2✔
888
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
889
            );
890

891
          cacheSyncPublisher.ifPresentOrElse(
7✔
892
            p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
893
            () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
894
          );
895
        } catch (RejectedExecutionException ree) {
×
896
          // hotKeyExecutor saturated mid-body (nextVersion/Redis did run, but
897
          // we cannot distinguish here — VersionController.nextVersion already
898
          // has its own Redis-degraded fallback path). Cache and broadcast are
899
          // skipped; the next read or the next periodic Worker cycle will
900
          // reconcile. Logged at ERROR so operators can detect silent drops.
901
          log.error(
×
902
            "putThrough executor rejected mid-flight for key={} (local cache NOT updated, broadcast NOT sent)",
903
            cacheKey,
904
            ree
905
          );
906
          throw ree; // CompletableFuture.exceptionally downstream decides policy
×
907
        } catch (Exception e) {
×
908
          // Redis-relayed exception or unexpected error. The local L1 is still
909
          // updated using a degraded version so the cache remains coherent with
910
          // the mutation that already succeeded on the writer.
911
          log.error("putThrough cache/broadcast failed for key={} (applying degraded local update)", cacheKey, e);
×
912
          var vr = versionController.fallbackVersion();
×
913

914
          caffeineCache
×
915
            .asMap()
×
916
            .compute(cacheKey, (k, existing) ->
×
917
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
918
            );
919
          cacheSyncPublisher.ifPresent(p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()));
×
920
        }
1✔
921
      },
1✔
922
      hotKeyExecutor
923
    );
924
  }
1✔
925

926
  /**
927
   * Builds a {@link CacheEntry} for a write-through operation, preserving
928
   * Worker-managed state and version information from the existing entry.
929
   *
930
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
931
   * @param value             the new value to cache
932
   * @param vr                the version result from the version controller
933
   * @param effectiveHardTtl  the effective hard TTL to use
934
   * @param effectiveSoftTtl  the effective soft TTL to use
935
   * @param <T>               the value type
936
   * @return a new {@link CacheEntry} with the supplied value and metadata
937
   */
938
  @SuppressWarnings("all")
939
  private <T> CacheEntry buildPutThroughEntry(
940
    Object existing,
941
    T value,
942
    VersionController.VersionResult vr,
943
    long effectiveHardTtl,
944
    long effectiveSoftTtl
945
  ) {
946
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
947
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
948
      return ce;
2✔
949
    }
950

951
    boolean isWorkerManaged = isWorkerManaged(existing);
3✔
952

953
    long decisionVersion = 0L;
2✔
954
    String decisionNodeId = null;
2✔
955
    long decisionEpoch = 0L;
2✔
956
    long normalHardTtl = effectiveHardTtl;
2✔
957
    long normalSoftTtl = effectiveSoftTtl;
2✔
958

959
    if (existing instanceof CacheEntry entry) {
6✔
960
      decisionVersion = entry.getDecisionVersion();
3✔
961
      decisionNodeId = entry.getDecisionNodeId();
3✔
962
      decisionEpoch = entry.getDecisionEpoch();
3✔
963
      if (isWorkerManaged) {
2!
964
        normalHardTtl = entry.getNormalHardTtlMs();
3✔
965
        normalSoftTtl = entry.getNormalSoftTtlMs();
3✔
966
      }
967
    }
968

969
    long hardTtl = effectiveHardTtl;
2✔
970
    long softTtl = effectiveSoftTtl;
2✔
971
    if (state == KeyState.HOT) {
3✔
972
      hardTtl = expireManager.resolveEffectiveHotHard(hardTtl);
5✔
973
      softTtl = expireManager.resolveEffectiveHotSoft(softTtl);
5✔
974
    }
975

976
    Object wrapValue = value != null ? value : NullValue.INSTANCE;
6✔
977
    return expireManager.createBuilder(
6✔
978
      wrapValue,
979
      vr.dataVersion(),
2✔
980
      vr.degraded(),
9✔
981
      decisionVersion,
982
      decisionNodeId,
983
      decisionEpoch,
984
      hardTtl,
985
      softTtl,
986
      normalHardTtl,
987
      normalSoftTtl,
988
      state
989
    );
990
  }
991

992
  /**
993
   * Write a value directly into the local L1 cache without version bump,
994
   * without broadcast, without hot-key detection, and without reporting.
995
   * <p>
996
   * Existing entry metadata is preserved.  If no entry exists, a fresh
997
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
998
   * <p>
999
   * Pass {@code 0} for either TTL to use the configured default.
1000
   *
1001
   * @param cacheKey  the key to store
1002
   * @param value     the value to cache
1003
   * @param hardTtlMs hard TTL override (0 = use configured default)
1004
   * @param softTtlMs soft TTL override (0 = use configured default)
1005
   */
1006
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
1007
    if (preGuard(cacheKey) == null) return;
5✔
1008

1009
    long hardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
1010
    long softTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
1011

1012
    caffeineCache
2✔
1013
      .asMap()
7✔
1014
      .compute(cacheKey, (k, existing) -> {
2✔
1015
        if (existing instanceof CacheEntry ce) {
6✔
1016
          return expireManager.applyTtl(expireManager.replaceEntryValue(ce, value), hardTtl, softTtl);
11✔
1017
        }
1018
        return expireManager.createBuilder(
13✔
1019
          value,
1020
          VERSION_DEFAULT,
1021
          false,
1022
          0L,
1023
          hardTtl,
1024
          softTtl,
1025
          hardTtl,
1026
          softTtl,
1027
          KeyState.NORMAL
1028
        );
1029
      });
1030
  }
1✔
1031

1032
  /**
1033
   * Execute a mutation, then invalidate L1 and broadcast.
1034
   * Next {@link #get} will re-fetch from the reader.
1035
   *
1036
   * @param cacheKey the key to invalidate after mutation
1037
   * @param mutation the mutation to execute
1038
   */
1039
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
1040
    if (preGuard(cacheKey) == null) return;
5✔
1041

1042
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
1043
      try {
1044
        mutation.run();
2✔
1045
      } catch (Exception e) {
1✔
1046
        log.error("putBeforeInvalidate mutation failed, skip invalidate and broadcast: {}", cacheKey, e);
5✔
1047
        return;
1✔
1048
      }
1✔
1049
      caffeineCache.invalidate(cacheKey);
4✔
1050
      bumpAndInvalidate(cacheKey);
3✔
1051
    });
1✔
1052
  }
1✔
1053

1054
  /**
1055
   * Add or remove a rule atomically within a transaction boundary.
1056
   * <p>Extracted from the four public rule methods to eliminate the
1057
   * identical validation-and-commit pattern.
1058
   *
1059
   * @param add {@code true} to add the rule, {@code false} to remove it
1060
   */
1061
  private void modifyRule(String cacheKey, RuleAction action, boolean add) {
1062
    if (invalidCacheKey(cacheKey)) {
3✔
1063
      log.debug("modifyRule: invalid cacheKey '{}'", cacheKey);
4✔
1064
      return;
1✔
1065
    }
1066
    TransactionSupport.runNowOrAfterCommit(() -> {
6✔
1067
      if (add) ruleMatcher.addRule(RuleMatcher.of(cacheKey, action));
9✔
1068
      else ruleMatcher.removeRule(cacheKey, action);
6✔
1069
      log.info("{} {}", add ? "Added" : "Removed", action);
9✔
1070
    });
1✔
1071
  }
1✔
1072

1073
  public void addBlacklist(String cacheKey) {
1074
    modifyRule(cacheKey, RuleAction.BLOCK, true);
5✔
1075
  }
1✔
1076

1077
  public void addWhitelist(String cacheKey) {
1078
    modifyRule(cacheKey, RuleAction.ALLOW_NO_REPORT, true);
5✔
1079
  }
1✔
1080

1081
  public void unBlacklist(String cacheKey) {
1082
    modifyRule(cacheKey, RuleAction.BLOCK, false);
5✔
1083
  }
1✔
1084

1085
  public void unWhitelist(String cacheKey) {
1086
    modifyRule(cacheKey, RuleAction.ALLOW_NO_REPORT, false);
5✔
1087
  }
1✔
1088

1089
  /**
1090
   * Evaluate all rules against the given key and return the first matching action.
1091
   *
1092
   * @param cacheKey the key to evaluate
1093
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
1094
   */
1095
  public RuleAction evaluateRule(String cacheKey) {
1096
    return ruleMatcher.evaluateRule(cacheKey);
5✔
1097
  }
1098

1099
  /**
1100
   * Return a snapshot of all current rules in evaluation order.
1101
   *
1102
   * @return list of rules (immutable snapshot)
1103
   */
1104
  public List<Rule> getAllRules() {
1105
    return ruleMatcher.getAllRules();
4✔
1106
  }
1107

1108
  /**
1109
   * Remove all blacklist and whitelist rules.
1110
   */
1111
  public void clearAllRules() {
1112
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1113
  }
1✔
1114

1115
  /**
1116
   * Broadcast all local rules to peer instances via the sync exchange.
1117
   * Useful for initial synchronization when a new instance joins the cluster.
1118
   */
1119
  public void broadcastAllLocalRulesManually() {
1120
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1121
  }
1✔
1122

1123
  /**
1124
   * Estimated number of entries currently in the L1 cache.
1125
   *
1126
   * @return best-effort estimate of the current entry count
1127
   */
1128
  public long estimatedSize() {
1129
    return caffeineCache.estimatedSize();
4✔
1130
  }
1131

1132
  /**
1133
   * Return a snapshot of basic L1 cache statistics.
1134
   * <p>
1135
   * Hit/miss/eviction counters are populated only when Caffeine's
1136
   * {@code recordStats()} is enabled.  {@code estimatedSizeOfKeysCount} is always
1137
   * available.
1138
   *
1139
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
1140
   *         if stats recording is not enabled
1141
   */
1142
  public HotKeyCacheStats stats() {
1143
    CacheStats cs = caffeineCache.stats();
4✔
1144
    return new HotKeyCacheStats(
4✔
1145
      cs.hitCount(),
2✔
1146
      cs.missCount(),
2✔
1147
      cs.hitRate(),
2✔
1148
      cs.evictionCount(),
3✔
1149
      caffeineCache.estimatedSize()
2✔
1150
    );
1151
  }
1152

1153
  /**
1154
   * Check whether the given key is blacklisted.
1155
   *
1156
   * @param cacheKey the key to check
1157
   * @return {@code true} if a blacklist rule matches the key
1158
   */
1159

1160
  public boolean isBlacklisted(String cacheKey) {
1161
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1162
  }
1163

1164
  /**
1165
   * Check whether the given key is whitelisted (skips Worker reporting).
1166
   *
1167
   * @param cacheKey the key to check
1168
   * @return {@code true} if a whitelist rule matches the key
1169
   */
1170
  public boolean isWhitelisted(String cacheKey) {
1171
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1172
  }
1173

1174
  /**
1175
   * Invalidate all entries from the L1 cache without broadcasting.
1176
   * <p>
1177
   * This is an emergency flush — all cached values are removed immediately.
1178
   * No cross-instance sync messages are sent.
1179
   */
1180
  public void invalidateAllLocal() {
1181
    caffeineCache.invalidateAll();
3✔
1182
  }
1✔
1183

1184
  /**
1185
   * Return the underlying Caffeine cache for direct access.
1186
   *
1187
   * <p>This provides access to Caffeine-specific operations such as
1188
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
1189
   * caution — bypassing the HotKey orchestration layer (version tracking,
1190
   * broadcast, expiry management) can lead to inconsistent state.
1191
   *
1192
   * @return the raw Caffeine {@link Cache} instance
1193
   */
1194
  public Cache<String, Object> getLocalCache() {
1195
    return caffeineCache;
3✔
1196
  }
1197

1198
  /**
1199
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1200
   * <p>
1201
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1202
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1203
   * transparently returned as {@code null} to callers.
1204
   *
1205
   * @param stored the raw value from a {@link CacheEntry}
1206
   * @return {@code null} if the sentinel, otherwise the original value
1207
   */
1208
  @Nullable
1209
  private Object unwrapValue(@Nullable Object stored) {
1210
    try {
1211
      Object val = compressor.unwrap(stored);
5✔
1212
      return val == NullValue.INSTANCE ? null : val;
7✔
1213
    } catch (IOException e) {
×
1214
      log.warn("Failed to decompress cache value", e);
×
1215
      return null;
×
1216
    }
1217
  }
1218
}
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