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

Hyshmily / hotkey / 28835396755

07 Jul 2026 01:38AM UTC coverage: 89.623% (-0.7%) from 90.336%
28835396755

push

github

Hyshmily
fix and feat : fix known bugs and accept lz4 to wrap value for smaller memory,"wrap key" needs to consider

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

1325 of 1552 branches covered (85.37%)

Branch coverage included in aggregate %.

167 of 236 new or added lines in 12 files covered. (70.76%)

3745 of 4105 relevant lines covered (91.23%)

4.19 hits per line

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

84.66
/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.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
  /** Optional publisher for cross-instance cache synchronization. */
85
  @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
86
  private final Optional<CacheSyncPublisher> cacheSyncPublisher;
87

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

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

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

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

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

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

109
  /**
110
   * Check whether an existing cache entry is managed by the Worker (HOT or COOL).
111
   * Worker-managed entries preserve their original normal TTLs through writes.
112
   *
113
   * @param existing the existing cache entry (maybe {@code null} or a raw value)
114
   * @return {@code true} if the entry is a {@link CacheEntry} with state HOT or COOL
115
   */
116
  private static boolean isWorkerManagedEntry(Object existing) {
117
    return (
1✔
118
      existing instanceof CacheEntry entry &&
7✔
119
      (entry.getKeyState() == KeyState.HOT || entry.getKeyState() == KeyState.COOL)
10✔
120
    );
121
  }
122

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

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

170
  /**
171
   * Look up a cached value without loading or triggering hot-key detection.
172
   * Unlike {@link #get}, this method never invokes the reader or SingleFlight.
173
   *
174
   * @param cacheKey the key to inspect
175
   * @return an {@link Optional} containing the raw value if present
176
   */
177
  @SuppressWarnings("unchecked")
178
  public <T> Optional<T> peek(String cacheKey) {
179
    if (invalidCacheKey(cacheKey)) {
3✔
180
      log.debug("peek: invalid cacheKey");
3✔
181
      return Optional.empty();
2✔
182
    }
183

184
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
185
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
186
    }
187

188
    try {
189
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey)).map(raw ->
9✔
190
        raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw
13✔
191
      );
192
    } catch (HotKeyBlockedException e) {
×
193
      throw e;
×
194
    } catch (RuntimeException e) {
×
195
      log.error("HotKeyCache.peek internal error for key={}, returning empty", cacheKey, e);
×
196
      return Optional.empty();
×
197
    }
198
  }
199

200
  /**
201
   * Get a value from L1 or load it via the reader.
202
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
203
   *
204
   * @param cacheKey the key to retrieve
205
   * @param reader   the value supplier for cache misses
206
   * @param <T>      the value type
207
   * @return an {@link Optional} containing the cached or loaded value
208
   * @throws HotKeyBlockedException when the key matches a blacklist rule
209
   */
210
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
211
    return get(cacheKey, reader, 0L, 0L);
7✔
212
  }
213

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

235
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
236
    if (action == RuleAction.BLOCK) {
3✔
237
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
238
    }
239
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
240

241
    try {
242
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
243
        .flatMap(raw -> {
8✔
244
          if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, raw)) {
6✔
245
            return Optional.empty();
2✔
246
          }
247

248
          T val = raw instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) raw;
13✔
249

250
          return processHitAndValidate(cacheKey, raw, val, hardTtlMs, softTtlMs, skipReport);
9✔
251
        })
252
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
253
    } catch (HotKeyBlockedException e) {
×
254
      throw e;
×
255
    } catch (RuntimeException e) {
×
256
      log.error("HotKeyCache.get internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
257
      return Optional.empty();
×
258
    }
259
  }
260

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

276
  /**
277
   * Get with soft-expire and explicit soft TTL override.
278
   *
279
   * @param cacheKey  the key to retrieve
280
   * @param reader    the value supplier for cache misses / refreshes
281
   * @param softTtlMs soft TTL override (0 = use configured default)
282
   * @param <T>       the value type
283
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
284
   * @throws HotKeyBlockedException when the key matches a blacklist rule
285
   */
286
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
287
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
288
  }
289

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

308
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
309
    if (action == RuleAction.BLOCK) {
3✔
310
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
311
    }
312
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
313

314
    if (!expireManager.isSoftExpireEnabled()) {
4✔
315
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
316
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
317
    }
318
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
319
    try {
320
      return Optional.ofNullable(raw)
11✔
321
        .flatMap(v -> {
8✔
322
          if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, v)) {
6✔
323
            return Optional.empty();
2✔
324
          }
325

326
          T cached = v instanceof CacheEntry vv ? (T) unwrapValue(vv.getValue()) : (T) v;
13✔
327

328
          if (isWorkerManagedEntry(v)) {
3✔
329
            CacheEntry ce = (CacheEntry) v;
3✔
330
            if (expireManager.isSoftExpired(ce)) {
5✔
331
              long effectiveSoft =
332
                softTtlMs > 0
4✔
333
                  ? softTtlMs
2✔
334
                  : (KeyState.HOT == ce.getKeyState()
4!
335
                      ? expireManager.getEffectiveHotSoftTtlMs()
×
336
                      : ce.getNormalSoftTtlMs() > 0
5✔
337
                        ? ce.getNormalSoftTtlMs()
3✔
338
                        : expireManager.getEffectiveSoftTtlMs());
4✔
339

340
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
341
            }
342
          }
343

344
          return processHitAndValidate(cacheKey, raw, cached, hardTtlMs, softTtlMs, skipReport);
9✔
345
        })
346
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
347
    } catch (HotKeyBlockedException e) {
×
348
      throw e;
×
349
    } catch (RuntimeException e) {
×
350
      log.error(
×
351
        "HotKeyCache.getWithSoftExpire internal error for key={}, returning empty to keep caller operational",
352
        cacheKey,
353
        e
354
      );
355
      return Optional.empty();
×
356
    }
357
  }
358

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

387
    // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
388
    if (wasProcessed || !skipReport) {
4✔
389
      Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
390
      if (expireManager.invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
6!
391
        return Optional.empty();
×
392
      }
393
    }
394

395
    return Optional.ofNullable(cached);
3✔
396
  }
397

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

420
    if (result.isEmpty()) {
3!
NEW
421
      return handleEmptyLoadResult(cacheKey);
×
422
    }
423
    T value = result.get();
3✔
424
    return Optional.of(mapLoadedValue(cacheKey, value, hardTtlMs, softTtlMs, skipReport));
9✔
425
  }
426

427
  /**
428
   * Handle the case when SingleFlight returns empty: either return a stale
429
   * entry when the circuit breaker is open, or cache a {@link NullValue}
430
   * sentinel with a short TTL and return {@link Optional#empty()}.
431
   *
432
   * @param cacheKey the key that was loaded (resulted in a null value)
433
   * @param <T>      the expected value type
434
   * @return a stale value if the circuit breaker is open and a cached entry
435
   *         exists; {@link Optional#empty()} otherwise (a NullValue sentinel
436
   *         is written to L1 in both cases)
437
   */
438
  @SuppressWarnings("unchecked")
439
  private <T> Optional<T> handleEmptyLoadResult(String cacheKey) {
NEW
440
    if (singleFlight.isBreakerOpen()) {
×
NEW
441
      Object stale = caffeineCache.getIfPresent(cacheKey);
×
442

NEW
443
      if (stale != null) {
×
NEW
444
        T val = stale instanceof CacheEntry ce ? (T) unwrapValue(ce.getValue()) : (T) stale;
×
NEW
445
        log.debug("CB open, returning stale entry for key={}", cacheKey);
×
NEW
446
        return Optional.ofNullable(val);
×
447
      }
448
    }
NEW
449
    long nullTtlMs = TimeUnit.SECONDS.toMillis(hotKeyProperties.getNullValueTtlSeconds());
×
NEW
450
    long nullExpireAtMs = expireManager.computeNullExpireAt(nullTtlMs);
×
451

NEW
452
    caffeineCache.put(
×
453
      cacheKey,
NEW
454
      expireManager.createBuilder(
×
455
        NullValue.INSTANCE,
456
        VERSION_DEFAULT,
457
        false,
458
        0L,
459
        nullTtlMs,
460
        0L,
461
        nullExpireAtMs,
462
        0L,
463
        0L,
464
        0L,
465
        KeyState.NORMAL
466
      )
467
    );
NEW
468
    return Optional.empty();
×
469
  }
470

471
  /**
472
   * Process a successfully loaded value: re-check blacklist, detect hot key via
473
   * HeavyKeeper, store in L1 with HOT or NORMAL TTL, and optionally report to Worker.
474
   *
475
   * @param cacheKey   the key to cache
476
   * @param value      the loaded value (must not be null at this point)
477
   * @param hardTtlMs  hard TTL override (0 = use configured default)
478
   * @param softTtlMs  soft TTL override (0 = use configured default)
479
   * @param skipReport if {@code true}, skip reporting to Worker
480
   * @param <T>        the value type
481
   * @return the loaded value (unchanged)
482
   */
483
  private <T> T mapLoadedValue(String cacheKey, T value, long hardTtlMs, long softTtlMs, boolean skipReport) {
484
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
NEW
485
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
486
    }
487

488
    long effectiveHard = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
489
    long effectiveSoft = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
490

491
    hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
492
    if (hotKeyDetector.contains(cacheKey)) {
5✔
493
      long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
494
      long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
495

496
      storeCacheEntry(cacheKey, value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
497
      recordIfReporting(skipReport, cacheKey);
4✔
498
      log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
499
    } else {
1✔
500
      storeCacheEntry(cacheKey, value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
501
      recordIfReporting(skipReport, cacheKey);
4✔
502
      log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
503
    }
504
    return value;
2✔
505
  }
506

507
  /**
508
   * Atomically insert or update the entry in L1 via {@code compute}, skipping
509
   * the update if the existing entry is Worker-managed (HOT or COOL).
510
   *
511
   * @param cacheKey      the key to store
512
   * @param value         the value to cache
513
   * @param hardTtlMs     the hard TTL for this entry
514
   * @param softTtlMs     the soft TTL for this entry
515
   * @param state         the key state to assign (HOT or NORMAL)
516
   * @param normalHardTtl the normal (non-hot) hard TTL to preserve for demotion
517
   * @param normalSoftTtl the normal (non-hot) soft TTL to preserve for demotion
518
   */
519
  private void storeCacheEntry(
520
    String cacheKey,
521
    Object value,
522
    long hardTtlMs,
523
    long softTtlMs,
524
    KeyState state,
525
    long normalHardTtl,
526
    long normalSoftTtl
527
  ) {
528
    caffeineCache
2✔
529
      .asMap()
10✔
530
      .compute(cacheKey, (k, existing) -> {
2✔
531
        if (isWorkerManagedEntry(existing)) {
3✔
532
          return existing;
2✔
533
        }
534
        return buildEntry(value, hardTtlMs, softTtlMs, state, normalHardTtl, normalSoftTtl);
9✔
535
      });
536
  }
1✔
537

538
  /**
539
   * Record a key access to the {@link KeyReporter} unless reporting is
540
   * explicitly skipped.
541
   *
542
   * @param skipReport if {@code true}, no-op
543
   * @param cacheKey   the key accessed
544
   */
545
  private void recordIfReporting(boolean skipReport, String cacheKey) {
546
    if (!skipReport) {
2✔
547
      hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
548
    }
549
  }
1✔
550

551
  private <T> CacheEntry buildEntry(
552
    T value,
553
    long hardTtlMs,
554
    long softTtlMs,
555
    KeyState state,
556
    long normalHardTtlMs,
557
    long normalSoftTtlMs
558
  ) {
559
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
560
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
561

562
    return expireManager.createBuilder(
15✔
563
      value,
564
      VERSION_DEFAULT,
565
      false,
566
      VERSION_DEFAULT,
567
      hardTtlMs,
568
      softTtlMs,
569
      hardTtlExpireAtMs,
570
      softTtlExpireAtMs,
571
      normalHardTtlMs,
572
      normalSoftTtlMs,
573
      state
574
    );
575
  }
576

577
  /**
578
   * Process a cache hit for local hot-key management: if the entry is already
579
   * HOT and more than half its TTL has elapsed, extend its expiry window;
580
   * otherwise promote eligible non-hot entries (NORMAL or COOL-when-all-dead)
581
   * to HOT if the local TopK now considers them hot.
582
   * <p>
583
   * HOT entries that are still within their first half are left untouched —
584
   * no need to re-insert the same state.
585
   *
586
   * @param cacheKey  the key to promote
587
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
588
   * @param val       the extracted value from the cache entry
589
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
590
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
591
   * @return {@code true} if a local promotion or expiry extension occurred
592
   */
593
  private boolean processLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
594
    if (raw instanceof CacheEntry ce && ce.getKeyState() == KeyState.HOT && ce.getHardExpireAtMs() != Long.MAX_VALUE) {
15✔
595
      long remainingTtl = ce.getHardExpireAtMs() - TimeSource.currentTimeMillis();
5✔
596
      long totalTtl = ce.getHardTtlMs();
3✔
597

598
      if (totalTtl > 0 && remainingTtl < totalTtl / 2) {
10!
599
        expireManager.extendExpiry(cacheKey, hardTtlMs, softTtlMs);
6✔
600
        return true;
2✔
601
      }
602
      return false;
2✔
603
    }
604

605
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
606
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
607
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
608
        return false;
2✔
609
      }
610
      long hotHard = expireManager.resolveEffectiveHotHard(hardTtlMs);
5✔
611
      long hotSoft = expireManager.resolveEffectiveHotSoft(softTtlMs);
5✔
612

613
      promoteEntryInCache(cacheKey, val, ce, hotHard, hotSoft);
7✔
614
      return true;
2✔
615
    }
616
    return false;
2✔
617
  }
618

619
  /**
620
   * Atomically promote a cache entry to HOT state in L1, respecting the
621
   * Worker-managed guard (entries in HOT/COOL state from the Worker are
622
   * left untouched).
623
   *
624
   * @param cacheKey the key to promote
625
   * @param val      the underlying cached value
626
   * @param ce       the existing {@link CacheEntry} from which metadata
627
   *                 (version, normal TTLs) is preserved
628
   * @param hotHard  the hot-entry hard TTL
629
   * @param hotSoft  the hot-entry soft TTL
630
   */
631
  private void promoteEntryInCache(String cacheKey, Object val, CacheEntry ce, long hotHard, long hotSoft) {
632
    caffeineCache
2✔
633
      .asMap()
8✔
634
      .compute(cacheKey, (k, existing) -> {
2✔
635
        if (existing instanceof CacheEntry entry) {
6!
636
          if (!isPromotableState(entry.getKeyState())) {
5!
NEW
637
            return existing;
×
638
          }
639

640
          return expireManager.applyTtl(entry, hotHard, hotSoft).toBuilder().keyState(KeyState.HOT).build();
11✔
641
        }
642

NEW
643
        return expireManager.createBuilder(
×
644
          val,
NEW
645
          ce.getDataVersion(),
×
NEW
646
          ce.isVersionDegraded(),
×
NEW
647
          ce.getDecisionVersion(),
×
648
          hotHard,
649
          hotSoft,
NEW
650
          ce.getNormalHardTtlMs(),
×
NEW
651
          ce.getNormalSoftTtlMs(),
×
652
          KeyState.HOT
653
        );
654
      });
655
  }
1✔
656

657
  /**
658
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
659
   * so they remove their local copy and re-fetch on next {@link #get}.
660
   * The next local {@link #get} will re-fetch from the reader.
661
   *
662
   * @param cacheKey the key to invalidate
663
   */
664
  public void invalidate(String cacheKey) {
665
    if (invalidCacheKey(cacheKey)) {
3✔
666
      log.debug("invalidate: invalid cacheKey");
3✔
667
      return;
1✔
668
    }
669

670
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
671
      // Local L1 must be dropped synchronously so subsequent reads see the
672
      // invalidation immediately, even if the executor is saturated.
673
      caffeineCache.invalidate(cacheKey);
4✔
674

675
      // Version bump (Redis INCR) + broadcast happen asynchronously. If
676
      // hotKeyExecutor rejects or Redis fails, VersionController.nextVersion
677
      // already falls back to a degraded local counter (ADR-0009), so the
678
      // broadcast still propagates with isVersionDegraded=true and peers
679
      // honor it via the 4-case VersionGuard comparison.
680
      try {
681
        hotKeyExecutor.execute(() -> {
6✔
682
          try {
683
            var vr = versionController.nextVersion(cacheKey);
5✔
684
            cacheSyncPublisher.ifPresentOrElse(
7✔
685
              p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
686
              () -> log.debug("invalidate async: " + NO_SYNC_PUBLISHER)
4✔
687
            );
688
          } catch (Exception ex) {
×
689
            log.error("invalidate async broadcast failed for key={}", cacheKey, ex);
×
690
          }
1✔
691
        });
1✔
692
      } catch (RejectedExecutionException ree) {
×
693
        log.warn(
×
694
          "invalidate executor rejected for key={}, peer invalidation deferred to next cycle: {}",
695
          cacheKey,
696
          ree.getMessage()
×
697
        );
698
      }
1✔
699
    });
1✔
700
  }
1✔
701

702
  /**
703
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
704
   * Invalid keys (null or blank) are silently skipped.
705
   *
706
   * @param cacheKeys the keys to invalidate
707
   */
708
  public void invalidateAll(Collection<String> cacheKeys) {
709
    List<String> validKeys = cacheKeys
1✔
710
      .stream()
2✔
711
      .filter(k -> !invalidCacheKey(k))
8✔
712
      .toList();
2✔
713
    if (validKeys.isEmpty()) {
3✔
714
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
715
      return;
1✔
716
    }
717

718
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
719
      caffeineCache.invalidateAll(validKeys);
4✔
720
      cacheSyncPublisher.ifPresentOrElse(
7✔
721
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
722
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
723
      );
724
    });
1✔
725
  }
1✔
726

727
  /**
728
   * Evict keys from the local cache only, without broadcasting to other
729
   * instances and without bumping version numbers.
730
   *
731
   * <p>Useful for emergency local cleanup, testing, or when a module is
732
   * taken offline and only the current node needs to be cleared.
733
   *
734
   * @param cacheKeys the keys to evict locally
735
   */
736
  public void evictLocal(Collection<String> cacheKeys) {
737
    List<String> validKeys = cacheKeys
1✔
738
      .stream()
2✔
739
      .filter(k -> !invalidCacheKey(k))
8✔
740
      .toList();
2✔
741
    if (validKeys.isEmpty()) {
3✔
742
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
743
      return;
1✔
744
    }
745
    caffeineCache.invalidateAll(validKeys);
4✔
746
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
747
  }
1✔
748

749
  /**
750
   * Write-through: execute the writer, then update L1 and broadcast.
751
   * Uses effective hard/soft TTL from configuration.
752
   *
753
   * @param cacheKey the key to write
754
   * @param value    the value to cache
755
   * @param writer   the data-source mutation to execute before caching
756
   * @param <T>      the value type
757
   * @throws HotKeyBlockedException when the key matches a blacklist rule
758
   */
759
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
760
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
761
  }
1✔
762

763
  /**
764
   * Write-through with explicit TTL overrides.
765
   * Pass 0 to use the configured default for that TTL type.
766
   *
767
   * @param cacheKey  the key to write
768
   * @param value     the value to cache
769
   * @param writer    the data-source mutation to execute before caching
770
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
771
   * @param softTtlMs soft TTL override (0 = use configured default)
772
   * @param <T>       the value type
773
   */
774
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
775
    if (invalidCacheKey(cacheKey)) {
3✔
776
      log.debug("putThrough: invalid cacheKey");
3✔
777
      return;
1✔
778
    }
779
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
780
      log.debug("putThrough: blocked by rule: {}", cacheKey);
4✔
781
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
782
    }
783

784
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
785

786
    TransactionSupport.runAsyncAfterCommit(
11✔
787
      () -> {
788
        try {
789
          writer.run();
2✔
790
          writerOk.compareAndSet(false, true);
5✔
791
        } catch (Exception e) {
×
792
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
793
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
794
          return;
×
795
        }
1✔
796

797
        long effectiveHardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
798
        long effectiveSoftTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
799

800
        try {
801
          var vr = versionController.nextVersion(cacheKey);
5✔
802

803
          caffeineCache
2✔
804
            .asMap()
8✔
805
            .compute(cacheKey, (k, existing) ->
2✔
806
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
807
            );
808

809
          cacheSyncPublisher.ifPresentOrElse(
7✔
810
            p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
811
            () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
812
          );
813
        } catch (RejectedExecutionException ree) {
×
814
          // hotKeyExecutor saturated mid-body (nextVersion/Redis did run, but
815
          // we cannot distinguish here — VersionController.nextVersion already
816
          // has its own Redis-degraded fallback path). Cache and broadcast are
817
          // skipped; the next read or the next periodic Worker cycle will
818
          // reconcile. Logged at ERROR so operators can detect silent drops.
819
          log.error(
×
820
            "putThrough executor rejected mid-flight for key={} (local cache NOT updated, broadcast NOT sent)",
821
            cacheKey,
822
            ree
823
          );
824
          throw ree; // CompletableFuture.exceptionally downstream decides policy
×
825
        } catch (Exception e) {
×
826
          // Redis-relayed exception or unexpected error. The local L1 is still
827
          // updated using a degraded version so the cache remains coherent with
828
          // the mutation that already succeeded on the writer.
829
          log.error("putThrough cache/broadcast failed for key={} (applying degraded local update)", cacheKey, e);
×
830
          var vr = versionController.fallbackVersion();
×
831

832
          caffeineCache
×
833
            .asMap()
×
834
            .compute(cacheKey, (k, existing) ->
×
835
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
836
            );
837
          cacheSyncPublisher.ifPresent(p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()));
×
838
        }
1✔
839
      },
1✔
840
      hotKeyExecutor
841
    );
842
  }
1✔
843

844
  /**
845
   * Builds a {@link CacheEntry} for a write-through operation, preserving
846
   * Worker-managed state and version information from the existing entry.
847
   *
848
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
849
   * @param value             the new value to cache
850
   * @param vr                the version result from the version controller
851
   * @param effectiveHardTtl  the effective hard TTL to use
852
   * @param effectiveSoftTtl  the effective soft TTL to use
853
   * @param <T>               the value type
854
   * @return a new {@link CacheEntry} with the supplied value and metadata
855
   */
856
  @SuppressWarnings("all")
857
  private <T> CacheEntry buildPutThroughEntry(
858
    Object existing,
859
    T value,
860
    VersionController.VersionResult vr,
861
    long effectiveHardTtl,
862
    long effectiveSoftTtl
863
  ) {
864
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
865
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
866
      return ce;
2✔
867
    }
868

869
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
870

871
    long decisionVersion = 0L;
2✔
872
    String decisionNodeId = null;
2✔
873
    long decisionEpoch = 0L;
2✔
874
    long normalHardTtl = effectiveHardTtl;
2✔
875
    long normalSoftTtl = effectiveSoftTtl;
2✔
876

877
    if (existing instanceof CacheEntry entry) {
6✔
878
      decisionVersion = entry.getDecisionVersion();
3✔
879
      decisionNodeId = entry.getDecisionNodeId();
3✔
880
      decisionEpoch = entry.getDecisionEpoch();
3✔
881
      if (isWorkerManaged) {
2!
882
        normalHardTtl = entry.getNormalHardTtlMs();
3✔
883
        normalSoftTtl = entry.getNormalSoftTtlMs();
3✔
884
      }
885
    }
886

887
    long hardTtl = effectiveHardTtl;
2✔
888
    long softTtl = effectiveSoftTtl;
2✔
889
    if (state == KeyState.HOT) {
3✔
890
      hardTtl = expireManager.resolveEffectiveHotHard(hardTtl);
5✔
891
      softTtl = expireManager.resolveEffectiveHotSoft(softTtl);
5✔
892
    }
893

894
    Object wrapValue = value != null ? value : NullValue.INSTANCE;
6✔
895
    return expireManager.createBuilder(
6✔
896
      wrapValue,
897
      vr.dataVersion(),
2✔
898
      vr.degraded(),
9✔
899
      decisionVersion,
900
      decisionNodeId,
901
      decisionEpoch,
902
      hardTtl,
903
      softTtl,
904
      normalHardTtl,
905
      normalSoftTtl,
906
      state
907
    );
908
  }
909

910
  /**
911
   * Write a value directly into the local L1 cache without version bump,
912
   * without broadcast, without hot-key detection, and without reporting.
913
   * <p>
914
   * Existing entry metadata is preserved.  If no entry exists, a fresh
915
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
916
   * <p>
917
   * Pass {@code 0} for either TTL to use the configured default.
918
   *
919
   * @param cacheKey  the key to store
920
   * @param value     the value to cache
921
   * @param hardTtlMs hard TTL override (0 = use configured default)
922
   * @param softTtlMs soft TTL override (0 = use configured default)
923
   */
924
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
925
    if (invalidCacheKey(cacheKey)) {
3✔
926
      log.debug("putLocal: invalid cacheKey");
3✔
927
      return;
1✔
928
    }
929
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
930
      log.debug("putLocal: blocked by rule: {}", cacheKey);
4✔
931
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
932
    }
933

934
    long hardTtl = expireManager.resolveEffectiveHardTtl(hardTtlMs);
5✔
935
    long softTtl = expireManager.resolveEffectiveSoftTtl(softTtlMs);
5✔
936

937
    caffeineCache
2✔
938
      .asMap()
7✔
939
      .compute(cacheKey, (k, existing) -> {
2✔
940
        if (existing instanceof CacheEntry ce) {
6✔
941
          return expireManager.applyTtl(expireManager.replaceEntryValue(ce, value), hardTtl, softTtl);
11✔
942
        }
943
        return expireManager.createBuilder(
13✔
944
          value,
945
          VERSION_DEFAULT,
946
          false,
947
          0L,
948
          hardTtl,
949
          softTtl,
950
          hardTtl,
951
          softTtl,
952
          KeyState.NORMAL
953
        );
954
      });
955
  }
1✔
956

957
  /**
958
   * Execute a mutation, then invalidate L1 and broadcast.
959
   * Next {@link #get} will re-fetch from the reader.
960
   *
961
   * @param cacheKey the key to invalidate after mutation
962
   * @param mutation the mutation to execute
963
   */
964
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
965
    if (invalidCacheKey(cacheKey)) {
3✔
966
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
967
      return;
1✔
968
    }
969
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
970
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
971
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
972
    }
973
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
974
      try {
975
        mutation.run();
2✔
976
      } catch (Exception e) {
1✔
977
        log.error("putBeforeInvalidate mutation failed, skip invalidate and broadcast: {}", cacheKey, e);
5✔
978
        return;
1✔
979
      }
1✔
980
      // Drop local L1 immediately — subsequent reads on this instance must
981
      // re-fetch the mutated value via the reader.
982
      caffeineCache.invalidate(cacheKey);
4✔
983

984
      try {
985
        hotKeyExecutor.execute(() -> {
6✔
986
          try {
987
            var vr = versionController.nextVersion(cacheKey);
5✔
988
            cacheSyncPublisher.ifPresentOrElse(
7✔
989
              p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
990
              () -> log.debug("putBeforeInvalidate async: {}", NO_SYNC_PUBLISHER)
5✔
991
            );
992
          } catch (Exception ex) {
×
993
            log.error("putBeforeInvalidate async broadcast failed for key={}", cacheKey, ex);
×
994
          }
1✔
995
        });
1✔
996
      } catch (RejectedExecutionException ree) {
×
997
        log.warn(
×
998
          "putBeforeInvalidate executor rejected for key={}, peer invalidation deferred: {}",
999
          cacheKey,
1000
          ree.getMessage()
×
1001
        );
1002
      }
1✔
1003
    });
1✔
1004
  }
1✔
1005

1006
  /**
1007
   * Add a key pattern to the blacklist.
1008
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
1009
   *
1010
   * @param cacheKey the key pattern to blacklist
1011
   */
1012
  public void addBlacklist(String cacheKey) {
1013
    if (invalidCacheKey(cacheKey)) {
3✔
1014
      log.debug("addBlacklist: invalid cacheKey '{}'", cacheKey);
4✔
1015
      return;
1✔
1016
    }
1017
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
1018
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK));
6✔
1019
      log.info("Blacklist added: '{}'", cacheKey);
4✔
1020
    });
1✔
1021
  }
1✔
1022

1023
  /**
1024
   * Add a key pattern to the whitelist.
1025
   * Matching keys are allowed but bypass app-to-Worker reporting.
1026
   *
1027
   * @param cacheKey the key pattern to whitelist
1028
   */
1029
  public void addWhitelist(String cacheKey) {
1030
    if (invalidCacheKey(cacheKey)) {
3✔
1031
      log.debug("addWhitelist: invalid cacheKey '{}'", cacheKey);
4✔
1032
      return;
1✔
1033
    }
1034
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
1035
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT));
6✔
1036
      log.info("Whitelist added: '{}'", cacheKey);
4✔
1037
    });
1✔
1038
  }
1✔
1039

1040
  /**
1041
   * Remove a key pattern from the blacklist.
1042
   *
1043
   * @param cacheKey the key pattern to remove from the blacklist
1044
   */
1045
  public void unBlacklist(String cacheKey) {
1046
    if (invalidCacheKey(cacheKey)) {
3✔
1047
      log.debug("unBlacklist: invalid cacheKey '{}'", cacheKey);
4✔
1048
      return;
1✔
1049
    }
1050
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
1051
      if (ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK)) {
6!
1052
        log.info("Blacklist removed: '{}'", cacheKey);
4✔
1053
      }
1054
    });
1✔
1055
  }
1✔
1056

1057
  /**
1058
   * Remove a key pattern from the whitelist.
1059
   *
1060
   * @param cacheKey the key pattern to remove from the whitelist
1061
   */
1062
  public void unWhitelist(String cacheKey) {
1063
    if (invalidCacheKey(cacheKey)) {
3✔
1064
      log.debug("unWhitelist: invalid cacheKey '{}'", cacheKey);
4✔
1065
      return;
1✔
1066
    }
1067
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
1068
      if (ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT)) {
6!
1069
        log.info("Whitelist removed: '{}'", cacheKey);
4✔
1070
      }
1071
    });
1✔
1072
  }
1✔
1073

1074
  /**
1075
   * Evaluate all rules against the given key and return the first matching action.
1076
   *
1077
   * @param cacheKey the key to evaluate
1078
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
1079
   */
1080
  public RuleAction evaluateRule(String cacheKey) {
1081
    return ruleMatcher.evaluateRule(cacheKey);
5✔
1082
  }
1083

1084
  /**
1085
   * Return a snapshot of all current rules in evaluation order.
1086
   *
1087
   * @return list of rules (immutable snapshot)
1088
   */
1089
  public List<Rule> getAllRules() {
1090
    return ruleMatcher.getAllRules();
4✔
1091
  }
1092

1093
  /**
1094
   * Remove all blacklist and whitelist rules.
1095
   */
1096
  public void clearAllRules() {
1097
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1098
  }
1✔
1099

1100
  /**
1101
   * Broadcast all local rules to peer instances via the sync exchange.
1102
   * Useful for initial synchronization when a new instance joins the cluster.
1103
   */
1104
  public void broadcastAllLocalRulesManually() {
1105
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1106
  }
1✔
1107

1108
  /**
1109
   * Estimated number of entries currently in the L1 cache.
1110
   *
1111
   * @return best-effort estimate of the current entry count
1112
   */
1113
  public long estimatedSize() {
1114
    return caffeineCache.estimatedSize();
4✔
1115
  }
1116

1117
  /**
1118
   * Return a snapshot of basic L1 cache statistics.
1119
   * <p>
1120
   * Hit/miss/eviction counters are populated only when Caffeine's
1121
   * {@code recordStats()} is enabled.  {@code estimatedSizeOfKeysCount} is always
1122
   * available.
1123
   *
1124
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
1125
   *         if stats recording is not enabled
1126
   */
1127
  public HotKeyCacheStats stats() {
1128
    CacheStats cs = caffeineCache.stats();
4✔
1129
    return new HotKeyCacheStats(
4✔
1130
      cs.hitCount(),
2✔
1131
      cs.missCount(),
2✔
1132
      cs.hitRate(),
2✔
1133
      cs.evictionCount(),
3✔
1134
      caffeineCache.estimatedSize()
2✔
1135
    );
1136
  }
1137

1138
  /**
1139
   * Check whether the given key is blacklisted.
1140
   *
1141
   * @param cacheKey the key to check
1142
   * @return {@code true} if a blacklist rule matches the key
1143
   */
1144

1145
  public boolean isBlacklisted(String cacheKey) {
1146
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1147
  }
1148

1149
  /**
1150
   * Check whether the given key is whitelisted (skips Worker reporting).
1151
   *
1152
   * @param cacheKey the key to check
1153
   * @return {@code true} if a whitelist rule matches the key
1154
   */
1155
  public boolean isWhitelisted(String cacheKey) {
1156
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1157
  }
1158

1159
  /**
1160
   * Invalidate all entries from the L1 cache without broadcasting.
1161
   * <p>
1162
   * This is an emergency flush — all cached values are removed immediately.
1163
   * No cross-instance sync messages are sent.
1164
   */
1165
  public void invalidateAllLocal() {
1166
    caffeineCache.invalidateAll();
3✔
1167
  }
1✔
1168

1169
  /**
1170
   * Return the underlying Caffeine cache for direct access.
1171
   *
1172
   * <p>This provides access to Caffeine-specific operations such as
1173
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
1174
   * caution — bypassing the HotKey orchestration layer (version tracking,
1175
   * broadcast, expiry management) can lead to inconsistent state.
1176
   *
1177
   * @return the raw Caffeine {@link Cache} instance
1178
   */
1179
  public Cache<String, Object> getLocalCache() {
1180
    return caffeineCache;
3✔
1181
  }
1182

1183
  /**
1184
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1185
   * <p>
1186
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1187
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1188
   * transparently returned as {@code null} to callers.
1189
   *
1190
   * @param stored the raw value from a {@link CacheEntry}
1191
   * @return {@code null} if the sentinel, otherwise the original value
1192
   */
1193
  @Nullable
1194
  private Object unwrapValue(@Nullable Object stored) {
1195
    try {
1196
      Object val = compressor.unwrap(stored);
5✔
1197
      return val == NullValue.INSTANCE ? null : val;
7✔
NEW
1198
    } catch (IOException e) {
×
NEW
1199
      log.warn("Failed to decompress cache value", e);
×
NEW
1200
      return null;
×
1201
    }
1202
  }
1203
}
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