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

Hyshmily / hotkey / 28425041198

30 Jun 2026 06:30AM UTC coverage: 90.88% (-0.3%) from 91.186%
28425041198

push

github

Hyshmily
refactor: simplify Worker cluster management and failure detection

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

1250 of 1437 branches covered (86.99%)

Branch coverage included in aggregate %.

10 of 21 new or added lines in 4 files covered. (47.62%)

1 existing line in 1 file now uncovered.

3543 of 3837 relevant lines covered (92.34%)

4.22 hits per line

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

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

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

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

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

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

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

215
  /**
216
   * Get a value from L1 or load it via the reader.
217
   * Hot keys are promoted to L1 with configured hot TTLs; normal keys use default TTLs.
218
   *
219
   * @param cacheKey the key to retrieve
220
   * @param reader   the value supplier for cache misses
221
   * @param <T>      the value type
222
   * @return an {@link Optional} containing the cached or loaded value
223
   * @throws HotKeyBlockedException when the key matches a blacklist rule
224
   */
225
  public <T> Optional<T> get(String cacheKey, Supplier<T> reader) {
226
    return get(cacheKey, reader, 0L, 0L);
7✔
227
  }
228

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

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

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

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

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

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

291
  /**
292
   * Get with soft-expire and explicit soft TTL override.
293
   *
294
   * @param cacheKey  the key to retrieve
295
   * @param reader    the value supplier for cache misses / refreshes
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
  public <T> Optional<T> getWithSoftExpire(String cacheKey, Supplier<T> reader, long softTtlMs) {
302
    return getWithSoftExpire(cacheKey, reader, 0L, softTtlMs);
7✔
303
  }
304

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1142
  public boolean isBlacklisted(String cacheKey) {
1143
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
1144
  }
1145

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

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

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

1180
  /**
1181
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1182
   * <p>
1183
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1184
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1185
   * transparently returned as {@code null} to callers.
1186
   *
1187
   * @param value the raw value from a {@link CacheEntry}
1188
   * @return {@code null} if the sentinel, otherwise the original value
1189
   */
1190
  @Nullable
1191
  private static Object unwrapNull(@Nullable Object value) {
1192
    return value == NullValue.INSTANCE ? null : value;
7✔
1193
  }
1194
}
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