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

Hyshmily / hotkey / 28316658488

28 Jun 2026 08:34AM UTC coverage: 91.178% (-0.05%) from 91.227%
28316658488

push

github

Hyshmily
test :fix CI tests

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

1264 of 1445 branches covered (87.47%)

Branch coverage included in aggregate %.

3583 of 3871 relevant lines covered (92.56%)

4.21 hits per line

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

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

18
import static io.github.hyshmily.hotkey.cache.CacheKeysPolicy.invalidCacheKey;
19

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

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

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

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

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

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

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

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

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

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

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

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

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

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

216
  /**
217
   * Get a value from L1 or load it via the reader.
218
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
219
   *
220
   * @param cacheKey the key to retrieve
221
   * @param reader   the value supplier for cache misses
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
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
227
    return get(cacheKey, reader, 0L, 0L);
7✔
228
  }
229

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

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

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

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

266
          return processHitAndValidate(cacheKey, raw, val, hardTtlMs, softTtlMs, skipReport);
9✔
267
        })
268
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
269
    } catch (HotKeyBlockedException e) {
×
270
      throw e;
×
271
    } catch (RuntimeException e) {
×
272
      log.error("HotKeyCache.get internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
273
      return Optional.empty();
×
274
    }
275
  }
276

277
  /**
278
   * Get with soft-expire (stale-while-revalidate). Returns cached value immediately
279
   * even if soft TTL expired, while triggering async refresh in background.
280
   * Only HOT and COOL entries are subject to soft expire.
281
   *
282
   * @param cacheKey the key to retrieve
283
   * @param reader   the value supplier for cache misses / refreshes
284
   * @param <T>      the value type
285
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
286
   * @throws HotKeyBlockedException when the key matches a blacklist rule
287
   */
288
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader) {
289
    return getWithSoftExpire(cacheKey, reader, 0L, 0L);
7✔
290
  }
291

292
  /**
293
   * Get with soft-expire and explicit soft TTL override.
294
   *
295
   * @param cacheKey  the key to retrieve
296
   * @param reader    the value supplier for cache misses / refreshes
297
   * @param softTtlMs soft TTL override (0 = use configured default)
298
   * @param <T>       the value type
299
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
300
   * @throws HotKeyBlockedException when the key matches a blacklist rule
301
   */
302
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
303
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
304
  }
305

306
  /**
307
   * Get with soft-expire and explicit hard/soft TTL overrides.
308
   *
309
   * @param cacheKey  the key to retrieve
310
   * @param reader    the value supplier for cache misses / refreshes
311
   * @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})
312
   * @param softTtlMs soft TTL override (0 = use configured default)
313
   * @param <T>       the value type
314
   * @return an {@link Optional} containing the cached (possibly stale) or loaded value
315
   * @throws HotKeyBlockedException when the key matches a blacklist rule
316
   */
317
  @SuppressWarnings("unchecked")
318
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long hardTtlMs, long softTtlMs) {
319
    if (invalidCacheKey(cacheKey)) {
3✔
320
      log.debug("getWithSoftExpire: invalid cacheKey");
3✔
321
      return Optional.empty();
2✔
322
    }
323

324
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
325
    if (action == RuleAction.BLOCK) {
3✔
326
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
327
    }
328
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
329

330
    if (!expireManager.isSoftExpireEnabled()) {
4✔
331
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
332
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
333
    }
334
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
335
    try {
336
      return Optional.ofNullable(raw)
11✔
337
        .flatMap(v -> {
8✔
338
          if (invalidateIfIsLogicallyExpired(cacheKey, raw)) {
5✔
339
            return Optional.empty();
2✔
340
          }
341

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

344
          if (
3✔
345
            v instanceof CacheEntry cacheEntry &&
5✔
346
            (KeyState.HOT == cacheEntry.getKeyState() || KeyState.COOL == cacheEntry.getKeyState())
6✔
347
          ) {
348
            if (expireManager.isSoftExpired(cacheEntry)) {
5✔
349
              long effectiveSoft =
350
                softTtlMs > 0
4✔
351
                  ? softTtlMs
2✔
352
                  : (KeyState.HOT == cacheEntry.getKeyState()
4!
353
                      ? expireManager.getEffectiveHotSoftTtlMs()
×
354
                      : cacheEntry.getNormalSoftTtlMs() > 0
5✔
355
                        ? cacheEntry.getNormalSoftTtlMs()
3✔
356
                        : expireManager.getEffectiveSoftTtlMs());
4✔
357

358
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
359
            }
360
          }
361

362
          return processHitAndValidate(cacheKey, raw, cached, hardTtlMs, softTtlMs, skipReport);
9✔
363
        })
364
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
365
    } catch (HotKeyBlockedException e) {
×
366
      throw e;
×
367
    } catch (RuntimeException e) {
×
368
      log.error(
×
369
        "HotKeyCache.getWithSoftExpire internal error for key={}, returning empty to keep caller operational",
370
        cacheKey,
371
        e
372
      );
373
      return Optional.empty();
×
374
    }
375
  }
376

377
  /**
378
   * Process a cache hit: trigger local hot-key promotion/renewal and report
379
   * to Worker, then re-check logical expiry (TOCTOU guard) after side effects.
380
   * <p>Extracted from {@link #get} and {@link #getWithSoftExpire} to eliminate
381
   * code duplication.
382
   *
383
   * @param cacheKey  the cache key
384
   * @param raw       the raw value from Caffeine (may be {@link CacheEntry} or bare)
385
   * @param cached    the unwrapped cached value
386
   * @param hardTtlMs hard TTL override
387
   * @param softTtlMs soft TTL override
388
   * @param skipReport if {@code true}, skip app-to-Worker reporting
389
   * @param <T>       the value type
390
   * @return an {@link Optional} containing the cached value, or empty if logically expired
391
   */
392
  private <T> Optional<T> processHitAndValidate(
393
    String cacheKey,
394
    Object raw,
395
    T cached,
396
    long hardTtlMs,
397
    long softTtlMs,
398
    boolean skipReport
399
  ) {
400
    boolean wasProcessed = processLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
401
    if (!skipReport) {
2✔
402
      hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
403
    }
404

405
    // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
406
    if (wasProcessed || !skipReport) {
4✔
407
      Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
408
      if (invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
5!
409
        return Optional.empty();
×
410
      }
411
    }
412

413
    return Optional.ofNullable(cached);
3✔
414
  }
415

416
  /**
417
   * Load via SingleFlight, detect hot key, cache with HOT or NORMAL TTL, and return value.
418
   *
419
   * @param cacheKey   the key to load
420
   * @param reader     the value supplier
421
   * @param hardTtlMs  hard TTL override (0 = use configured default)
422
   * @param softTtlMs  soft TTL override (0 = use configured default)
423
   * @param skipReport if {@code true}, skip reporting to Worker
424
   */
425
  @SuppressWarnings("unchecked")
426
  private <T> Optional<T> loadAndCache(
427
    String cacheKey,
428
    Supplier<T> reader,
429
    long hardTtlMs,
430
    long softTtlMs,
431
    boolean skipReport
432
  ) {
433
    // Secondary check: rule may have been added after the entry check in get/getWithSoftExpire
434
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
435
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
436
    }
437
    Optional<T> result = singleFlight.load(cacheKey, reader);
6✔
438

439
    if (result.isEmpty()) {
3!
440
      if (singleFlight.isBreakerOpen()) {
×
441
        Object stale = caffeineCache.getIfPresent(cacheKey);
×
442

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

452
      caffeineCache.put(
×
453
        cacheKey,
454
        CacheEntry.builder()
×
455
          .value(NullValue.INSTANCE)
×
456
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
×
457
          .isVersionDegraded(false)
×
458
          .decisionVersion(0L)
×
459
          .hardTtlMs(nullTtlMs)
×
460
          .hardExpireAtMs(nullExpireAtMs)
×
461
          .softTtlMs(0)
×
462
          .softExpireAtMs(0)
×
463
          .keyState(KeyState.NORMAL)
×
464
          .build()
×
465
      );
466
      return Optional.empty();
×
467
    }
468
    return result.map(value -> {
9✔
469
      if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6!
470
        throw new HotKeyBlockedException("HotKeyCache", cacheKey);
×
471
      }
472

473
      long effectiveHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
474
      long effectiveSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
475

476
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
477
      if (hotKeyDetector.contains(cacheKey)) {
5✔
478
        long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
10✔
479
        long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
10✔
480

481
        caffeineCache
2✔
482
          .asMap()
9✔
483
          .compute(cacheKey, (k, existing) -> {
2✔
484
            if (isWorkerManagedEntry(existing)) {
3✔
485
              return existing;
2✔
486
            }
487
            return buildEntry(value, hotHard, hotSoft, KeyState.HOT, effectiveHard, effectiveSoft);
9✔
488
          });
489

490
        if (!skipReport) {
2✔
491
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
492
        }
493
        log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
494
      } else {
1✔
495
        caffeineCache
2✔
496
          .asMap()
7✔
497
          .compute(cacheKey, (k, existing) -> {
2✔
498
            if (isWorkerManagedEntry(existing)) {
3✔
499
              return existing;
2✔
500
            }
501
            return buildEntry(value, effectiveHard, effectiveSoft, KeyState.NORMAL, effectiveHard, effectiveSoft);
9✔
502
          });
503

504
        if (!skipReport) {
2✔
505
          hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
506
        }
507
        log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
508
      }
509
      return value;
2✔
510
    });
511
  }
512

513
  private <T> CacheEntry buildEntry(
514
    T value,
515
    long hardTtlMs,
516
    long softTtlMs,
517
    KeyState state,
518
    long normalHardTtlMs,
519
    long normalSoftTtlMs
520
  ) {
521
    long hardTtlExpireAtMs = expireManager.computeHardExpireAt(hardTtlMs);
5✔
522
    long softTtlExpireAtMs = softTtlMs > 0 ? expireManager.computeSoftExpireAt(softTtlMs) : 0L;
11✔
523

524
    return CacheEntry.builder()
3✔
525
      .value(value)
2✔
526
      .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
527
      .isVersionDegraded(false)
2✔
528
      .decisionVersion(0L)
2✔
529
      .hardTtlMs(hardTtlMs)
2✔
530
      .hardExpireAtMs(hardTtlExpireAtMs)
2✔
531
      .softTtlMs(softTtlMs)
2✔
532
      .softExpireAtMs(softTtlExpireAtMs)
2✔
533
      .keyState(state)
2✔
534
      .normalHardTtlMs(normalHardTtlMs)
2✔
535
      .normalSoftTtlMs(normalSoftTtlMs)
1✔
536
      .build();
1✔
537
  }
538

539
  /**
540
   * Process a cache hit for local hot-key management: if the entry is already
541
   * HOT and more than half its TTL has elapsed, extend its expiry window;
542
   * otherwise promote eligible non-hot entries (NORMAL or COOL-when-all-dead)
543
   * to HOT if the local TopK now considers them hot.
544
   * <p>
545
   * HOT entries that are still within their first half are left untouched —
546
   * no need to re-insert the same state.
547
   *
548
   * @param cacheKey  the key to promote
549
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
550
   * @param val       the extracted value from the cache entry
551
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
552
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
553
   * @return {@code true} if a local promotion or expiry extension occurred
554
   */
555
  private boolean processLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
556
    if (raw instanceof CacheEntry ce && ce.getKeyState() == KeyState.HOT && ce.getHardExpireAtMs() != Long.MAX_VALUE) {
15✔
557
      long remainingTtl = ce.getHardExpireAtMs() - TimeSource.currentTimeMillis();
5✔
558
      long totalTtl = ce.getHardTtlMs();
3✔
559

560
      if (totalTtl > 0 && remainingTtl < totalTtl / 2) {
10!
561
        extendHotExpiry(cacheKey, hardTtlMs, softTtlMs);
5✔
562
        return true;
2✔
563
      }
564
      return false;
2✔
565
    }
566

567
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
568
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
569
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
570
        return false;
2✔
571
      }
572
      long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
8!
573
      long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
8!
574
      long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
575
      long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
576

577
      caffeineCache
2✔
578
        .asMap()
10✔
579
        .compute(cacheKey, (k, existing) -> {
2✔
580
          if (existing instanceof CacheEntry entry) {
6!
581
            if (!isPromotableState(entry.getKeyState())) {
5!
582
              return existing;
×
583
            }
584

585
            return entry
2✔
586
              .toBuilder()
2✔
587
              .hardTtlMs(hotHard)
2✔
588
              .softTtlMs(hotSoft)
2✔
589
              .hardExpireAtMs(hotHardExpireAtMs)
2✔
590
              .softExpireAtMs(hotSoftExpireAtMs)
2✔
591
              .keyState(KeyState.HOT)
1✔
592
              .build();
1✔
593
          }
594

595
          return CacheEntry.builder()
×
596
            .value(val)
×
597
            .dataVersion(ce.getDataVersion())
×
598
            .isVersionDegraded(ce.isVersionDegraded())
×
599
            .decisionVersion(ce.getDecisionVersion())
×
600
            .hardTtlMs(hotHard)
×
601
            .hardExpireAtMs(hotHardExpireAtMs)
×
602
            .softTtlMs(hotSoft)
×
603
            .softExpireAtMs(hotSoftExpireAtMs)
×
604
            .keyState(KeyState.HOT)
×
605
            .normalHardTtlMs(ce.getNormalHardTtlMs())
×
606
            .normalSoftTtlMs(ce.getNormalSoftTtlMs())
×
607
            .build();
×
608
        });
609
      return true;
2✔
610
    }
611
    return false;
2✔
612
  }
613

614
  /**
615
   * Extend the expiry window of an existing HOT entry without changing its
616
   * key state. Re-computes hard and soft expiration timestamps from the
617
   * configured hot TTLs (or overrides). Only applies when the entry is still
618
   * {@link KeyState#HOT} at the time of the atomic update — if the state
619
   * changed concurrently the entry is left untouched.
620
   *
621
   * @param cacheKey  the key whose expiry to extend
622
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
623
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
624
   */
625
  private void extendHotExpiry(String cacheKey, long hardTtlMs, long softTtlMs) {
626
    long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
8!
627
    long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
8!
628
    long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
629
    long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
630

631
    caffeineCache
2✔
632
      .asMap()
7✔
633
      .computeIfPresent(cacheKey, (k, existing) -> {
2✔
634
        if (existing instanceof CacheEntry entry && entry.getKeyState() == KeyState.HOT) {
10!
635
          return entry
2✔
636
            .toBuilder()
2✔
637
            .hardTtlMs(hotHard)
2✔
638
            .softTtlMs(hotSoft)
2✔
639
            .hardExpireAtMs(hotHardExpireAtMs)
2✔
640
            .softExpireAtMs(hotSoftExpireAtMs)
1✔
641
            .build();
1✔
642
        }
643
        return existing;
×
644
      });
645
  }
1✔
646

647
  /**
648
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
649
   * so they remove their local copy and re-fetch on next {@link #get}.
650
   * The next local {@link #get} will re-fetch from the reader.
651
   *
652
   * @param cacheKey the key to invalidate
653
   */
654
  public void invalidate(String cacheKey) {
655
    if (invalidCacheKey(cacheKey)) {
3✔
656
      log.debug("invalidate: invalid cacheKey");
3✔
657
      return;
1✔
658
    }
659

660
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
661
      // Local L1 must be dropped synchronously so subsequent reads see the
662
      // invalidation immediately, even if the executor is saturated.
663
      caffeineCache.invalidate(cacheKey);
4✔
664

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

692
  /**
693
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
694
   * Invalid keys (null or blank) are silently skipped.
695
   *
696
   * @param cacheKeys the keys to invalidate
697
   */
698
  public void invalidateAll(Collection<String> cacheKeys) {
699
    List<String> validKeys = cacheKeys
1✔
700
      .stream()
2✔
701
      .filter(k -> !invalidCacheKey(k))
8✔
702
      .toList();
2✔
703
    if (validKeys.isEmpty()) {
3✔
704
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
705
      return;
1✔
706
    }
707

708
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
709
      caffeineCache.invalidateAll(validKeys);
4✔
710
      cacheSyncPublisher.ifPresentOrElse(
7✔
711
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
712
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
713
      );
714
    });
1✔
715
  }
1✔
716

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

739
  /**
740
   * Write-through: execute the writer, then update L1 and broadcast.
741
   * Uses effective hard/soft TTL from configuration.
742
   *
743
   * @param cacheKey the key to write
744
   * @param value    the value to cache
745
   * @param writer   the data-source mutation to execute before caching
746
   * @param <T>      the value type
747
   * @throws HotKeyBlockedException when the key matches a blacklist rule
748
   */
749
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
750
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
751
  }
1✔
752

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

774
    AtomicBoolean writerOk = new AtomicBoolean(false);
5✔
775

776
    TransactionSupport.runAsyncAfterCommit(
11✔
777
      () -> {
778
        try {
779
          writer.run();
2✔
780
          writerOk.compareAndSet(false, true);
5✔
781
        } catch (Exception e) {
×
782
          // Writer failed — do NOT cache, do NOT broadcast, do NOT bump version.
783
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
784
          return;
×
785
        }
1✔
786

787
        long effectiveHardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
788
        long effectiveSoftTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
789

790
        try {
791
          var vr = versionController.nextVersion(cacheKey);
5✔
792

793
          caffeineCache
2✔
794
            .asMap()
8✔
795
            .compute(cacheKey, (k, existing) ->
2✔
796
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
797
            );
798

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

822
          caffeineCache
×
823
            .asMap()
×
824
            .compute(cacheKey, (k, existing) ->
×
825
              buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
×
826
            );
827
          cacheSyncPublisher.ifPresent(p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()));
×
828
        }
1✔
829
      },
1✔
830
      hotKeyExecutor
831
    );
832
  }
1✔
833

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

858
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
859

860
    long normalHardTtl = 0;
2✔
861
    long normalSoftTtl = 0;
2✔
862

863
    long decisionVersion = (existing instanceof CacheEntry entry) ? entry.getDecisionVersion() : 0L;
11✔
864
    String decisionNodeId = (existing instanceof CacheEntry entry) ? entry.getDecisionNodeId() : null;
11✔
865
    long decisionEpoch = (existing instanceof CacheEntry entry) ? entry.getDecisionEpoch() : 0L;
11✔
866

867
    if (existing instanceof CacheEntry) {
3✔
868
      normalHardTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalHardTtlMs() : effectiveHardTtl;
7!
869
    }
870
    if (existing instanceof CacheEntry) {
3✔
871
      normalSoftTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalSoftTtlMs() : effectiveSoftTtl;
7!
872
    }
873

874
    return CacheEntry.builder()
2✔
875
      .value(value != null ? value : NullValue.INSTANCE)
7✔
876
      .dataVersion(vr.dataVersion())
3✔
877
      .isVersionDegraded(vr.degraded())
3✔
878
      .decisionVersion(decisionVersion)
2✔
879
      .decisionNodeId(decisionNodeId)
2✔
880
      .decisionEpoch(decisionEpoch)
1✔
881
      .hardTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotHardTtlMs() : effectiveHardTtl)
9✔
882
      .hardExpireAtMs(
1✔
883
        state == KeyState.HOT
3✔
884
          ? expireManager.computeHotHardExpireAt()
4✔
885
          : expireManager.computeHardExpireAt(effectiveHardTtl)
4✔
886
      )
887
      .softTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotSoftTtlMs() : effectiveSoftTtl)
9✔
888
      .softExpireAtMs(
2✔
889
        state == KeyState.HOT
3✔
890
          ? expireManager.computeHotSoftExpireAt()
4✔
891
          : expireManager.computeSoftExpireAt(effectiveSoftTtl)
4✔
892
      )
893
      .keyState(state)
2✔
894
      .normalHardTtlMs(normalHardTtl)
2✔
895
      .normalSoftTtlMs(normalSoftTtl)
1✔
896
      .build();
1✔
897
  }
898

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

923
    long hardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
924
    long softTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
925

926
    caffeineCache
2✔
927
      .asMap()
7✔
928
      .compute(cacheKey, (k, existing) -> {
2✔
929
        if (existing instanceof CacheEntry ce) {
6✔
930
          return ce
2✔
931
            .toBuilder()
2✔
932
            .value(value)
2✔
933
            .hardTtlMs(hardTtl)
2✔
934
            .softTtlMs(softTtl)
4✔
935
            .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
5✔
936
            .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
2✔
937
            .build();
1✔
938
        }
939
        return CacheEntry.builder()
3✔
940
          .value(value)
2✔
941
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
942
          .isVersionDegraded(false)
2✔
943
          .decisionVersion(0L)
2✔
944
          .hardTtlMs(hardTtl)
4✔
945
          .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
3✔
946
          .softTtlMs(softTtl)
4✔
947
          .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
3✔
948
          .keyState(KeyState.NORMAL)
2✔
949
          .normalHardTtlMs(hardTtl)
2✔
950
          .normalSoftTtlMs(softTtl)
1✔
951
          .build();
1✔
952
      });
953
  }
1✔
954

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

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

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

1018
  /**
1019
   * Add a key pattern to the whitelist.
1020
   * Matching keys are allowed but bypass app-to-Worker reporting.
1021
   *
1022
   * @param cacheKey the key pattern to whitelist
1023
   */
1024
  public void addWhitelist(String cacheKey) {
1025
    if (invalidCacheKey(cacheKey)) {
3✔
1026
      log.debug("whitelist: invalid cacheKey");
3✔
1027
      return;
1✔
1028
    }
1029
    TransactionSupport.runNowOrAfterCommit(() ->
4✔
1030
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT))
7✔
1031
    );
1032
  }
1✔
1033

1034
  /**
1035
   * Remove a key pattern from the blacklist.
1036
   *
1037
   * @param cacheKey the key pattern to remove from the blacklist
1038
   */
1039
  public void unBlacklist(String cacheKey) {
1040
    if (invalidCacheKey(cacheKey)) {
3✔
1041
      log.debug("unblacklist: invalid cacheKey");
3✔
1042
      return;
1✔
1043
    }
1044
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK));
11✔
1045
  }
1✔
1046

1047
  /**
1048
   * Remove a key pattern from the whitelist.
1049
   *
1050
   * @param cacheKey the key pattern to remove from the whitelist
1051
   */
1052
  public void unWhitelist(String cacheKey) {
1053
    if (invalidCacheKey(cacheKey)) {
3✔
1054
      log.debug("unwhitelist: invalid cacheKey");
3✔
1055
      return;
1✔
1056
    }
1057
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT));
11✔
1058
  }
1✔
1059

1060
  /**
1061
   * Evaluate all rules against the given key and return the first matching action.
1062
   *
1063
   * @param cacheKey the key to evaluate
1064
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
1065
   */
1066
  public RuleAction evaluateRule(String cacheKey) {
1067
    return ruleMatcher.evaluateRule(cacheKey);
5✔
1068
  }
1069

1070
  /**
1071
   * Return a snapshot of all current rules in evaluation order.
1072
   *
1073
   * @return list of rules (immutable snapshot)
1074
   */
1075
  public List<Rule> getAllRules() {
1076
    return ruleMatcher.getAllRules();
4✔
1077
  }
1078

1079
  /**
1080
   * Remove all blacklist and whitelist rules.
1081
   */
1082
  public void clearAllRules() {
1083
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
1084
  }
1✔
1085

1086
  /**
1087
   * Broadcast all local rules to peer instances via the sync exchange.
1088
   * Useful for initial synchronization when a new instance joins the cluster.
1089
   */
1090
  public void broadcastAllLocalRulesManually() {
1091
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
1092
  }
1✔
1093

1094
  /**
1095
   * Estimated number of entries currently in the L1 cache.
1096
   *
1097
   * @return best-effort estimate of the current entry count
1098
   */
1099
  public long estimatedSize() {
1100
    return caffeineCache.estimatedSize();
4✔
1101
  }
1102

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

1124
  /**
1125
   * Check whether the given key is blacklisted.
1126
   *
1127
   * @param cacheKey the key to check
1128
   * @return {@code true} if a blacklist rule matches the key
1129
   */
1130

1131
  public boolean isBlacklisted(String cacheKey) {
1132
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1133
  }
1134

1135
  /**
1136
   * Check whether the given key is whitelisted (skips Worker reporting).
1137
   *
1138
   * @param cacheKey the key to check
1139
   * @return {@code true} if a whitelist rule matches the key
1140
   */
1141
  public boolean isWhitelisted(String cacheKey) {
1142
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
1143
  }
1144

1145
  /**
1146
   * Invalidate all entries from the L1 cache without broadcasting.
1147
   * <p>
1148
   * This is an emergency flush — all cached values are removed immediately.
1149
   * No cross-instance sync messages are sent.
1150
   */
1151
  public void invalidateAllLocal() {
1152
    caffeineCache.invalidateAll();
3✔
1153
  }
1✔
1154

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

1169
  /**
1170
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1171
   * <p>
1172
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1173
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1174
   * transparently returned as {@code null} to callers.
1175
   *
1176
   * @param value the raw value from a {@link CacheEntry}
1177
   * @return {@code null} if the sentinel, otherwise the original value
1178
   */
1179
  @Nullable
1180
  private static Object unwrapNull(@Nullable Object value) {
1181
    return value == NullValue.INSTANCE ? null : value;
7✔
1182
  }
1183
}
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