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

Hyshmily / hotkey / 28159619024

25 Jun 2026 09:10AM UTC coverage: 92.667% (-0.02%) from 92.685%
28159619024

push

github

Hyshmily
release: v1.1.52

1199 of 1351 branches covered (88.75%)

Branch coverage included in aggregate %.

3426 of 3640 relevant lines covered (94.12%)

4.25 hits per line

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

91.39
/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.cache.annotationsupporter.NullValue;
23
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
24
import io.github.hyshmily.hotkey.exception.HotKeyBlockedException;
25
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
26
import io.github.hyshmily.hotkey.model.CacheEntry;
27
import io.github.hyshmily.hotkey.model.HotKeyCacheStats;
28
import io.github.hyshmily.hotkey.model.KeyState;
29
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
30
import io.github.hyshmily.hotkey.rule.Rule;
31
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
32
import io.github.hyshmily.hotkey.rule.RuleMatcher;
33
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
34
import io.github.hyshmily.hotkey.sharding.RingManager;
35
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
36
import io.github.hyshmily.hotkey.util.version.VersionController;
37
import io.github.hyshmily.hotkey.util.version.VersionGuard;
38
import jakarta.annotation.Nullable;
39
import java.util.Collection;
40
import java.util.List;
41
import java.util.Optional;
42
import java.util.concurrent.Executor;
43
import java.util.function.Supplier;
44
import lombok.RequiredArgsConstructor;
45
import lombok.extern.slf4j.Slf4j;
46

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

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

81
  /** Cached view of Worker cluster health, used for COOL promotion decisions. */
82
  private final ClusterHealthView healthView;
83

84
  /** Log message constant when no sync publisher is available. */
85
  private static final String NO_SYNC_PUBLISHER = HotKeyConstants.NO_SYNC_PUBLISHER;
86

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

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

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

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

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

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

194
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
195
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
196
    }
197

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

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

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

245
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
246
    if (action == RuleAction.BLOCK) {
3✔
247
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
248
    }
249
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
250

251
    try {
252
      return Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
12✔
253
        .flatMap(raw -> {
8✔
254
          if (invalidateIfIsLogicallyExpired(cacheKey, raw)) {
5✔
255
            return Optional.empty();
2✔
256
          }
257

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

260
          boolean wasPromoted = promoteLocalHotkeyIfNeeded(cacheKey, raw, val, hardTtlMs, softTtlMs);
8✔
261
          if (!skipReport) {
2✔
262
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
263
          }
264

265
          // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
266
          if (wasPromoted || !skipReport) {
4✔
267
            Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
268
            if (invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
5!
269
              return Optional.empty();
×
270
            }
271
          }
272

273
          return Optional.ofNullable(val);
3✔
274
        })
275
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
276
    } catch (HotKeyBlockedException e) {
×
277
      throw e;
×
278
    } catch (RuntimeException e) {
×
279
      log.error("HotKeyCache.get internal error for key={}, returning empty to keep caller operational", cacheKey, e);
×
280
      return Optional.empty();
×
281
    }
282
  }
283

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

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

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

331
    RuleAction action = ruleMatcher.evaluateRule(cacheKey);
5✔
332
    if (action == RuleAction.BLOCK) {
3✔
333
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
334
    }
335
    boolean skipReport = action == RuleAction.ALLOW_NO_REPORT;
7✔
336

337
    if (!expireManager.isSoftExpireEnabled()) {
4✔
338
      log.debug("getWithSoftExpire: soft expire not enabled, fallback to get()");
3✔
339
      return get(cacheKey, reader, hardTtlMs, softTtlMs);
7✔
340
    }
341
    Object raw = caffeineCache.getIfPresent(cacheKey);
5✔
342
    try {
343
      return Optional.ofNullable(raw)
11✔
344
        .flatMap(v -> {
8✔
345
          if (invalidateIfIsLogicallyExpired(cacheKey, raw)) {
5✔
346
            return Optional.empty();
2✔
347
          }
348

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

351
          if (
3✔
352
            v instanceof CacheEntry cacheEntry &&
5✔
353
            (KeyState.HOT == cacheEntry.getKeyState() || KeyState.COOL == cacheEntry.getKeyState())
6✔
354
          ) {
355
            if (expireManager.isSoftExpired(cacheEntry)) {
5✔
356
              long effectiveSoft =
357
                softTtlMs > 0
4✔
358
                  ? softTtlMs
2✔
359
                  : (KeyState.HOT == cacheEntry.getKeyState()
4✔
360
                      ? expireManager.getEffectiveHotSoftTtlMs()
4✔
361
                      : cacheEntry.getNormalSoftTtlMs() > 0
5✔
362
                        ? cacheEntry.getNormalSoftTtlMs()
3✔
363
                        : expireManager.getEffectiveSoftTtlMs());
4✔
364

365
              expireManager.triggerBackgroundRefresh(cacheKey, reader, effectiveSoft);
6✔
366
            }
367
          }
368

369
          boolean wasPromoted = promoteLocalHotkeyIfNeeded(cacheKey, raw, cached, hardTtlMs, softTtlMs);
8✔
370
          if (!skipReport) {
2✔
371
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
372
          }
373

374
          // TOCTOU: re-read from cache after side effects (promote/record may have taken time)
375
          if (wasPromoted || !skipReport) {
4!
376
            Object currentRaw = caffeineCache.getIfPresent(cacheKey);
5✔
377
            if (invalidateIfIsLogicallyExpired(cacheKey, currentRaw)) {
5!
378
              return Optional.empty();
×
379
            }
380
          }
381

382
          return Optional.ofNullable(cached);
3✔
383
        })
384
        .or(() -> loadAndCache(cacheKey, reader, hardTtlMs, softTtlMs, skipReport));
9✔
385
    } catch (HotKeyBlockedException e) {
×
386
      throw e;
×
387
    } catch (RuntimeException e) {
×
388
      log.error(
×
389
        "HotKeyCache.getWithSoftExpire internal error for key={}, returning empty to keep caller operational",
390
        cacheKey,
391
        e
392
      );
393
      return Optional.empty();
×
394
    }
395
  }
396

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

424
        long effectiveHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
425
        long effectiveSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
426

427
        hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
428
        if (hotKeyDetector.contains(cacheKey)) {
5✔
429
          long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
10✔
430
          long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
10✔
431
          long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
432
          long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
433

434
          caffeineCache
2✔
435
            .asMap()
10✔
436
            .compute(cacheKey, (k, existing) -> {
2✔
437
              if (isWorkerManagedEntry(existing)) {
3✔
438
                return existing;
2✔
439
              }
440
              return CacheEntry.builder()
3✔
441
                .value(value)
2✔
442
                .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
443
                .isVersionDegraded(false)
2✔
444
                .decisionVersion(0L)
2✔
445
                .hardTtlMs(hotHard)
2✔
446
                .hardExpireAtMs(hotHardExpireAtMs)
2✔
447
                .softTtlMs(hotSoft)
2✔
448
                .softExpireAtMs(hotSoftExpireAtMs)
2✔
449
                .keyState(KeyState.HOT)
2✔
450
                .normalHardTtlMs(effectiveHard)
2✔
451
                .normalSoftTtlMs(effectiveSoft)
1✔
452
                .build();
1✔
453
            });
454

455
          if (!skipReport) {
2✔
456
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
457
          }
458
          log.debug("HotKey detected, promoted to L1{}: {}", skipReport ? " (no report)" : " and reported", cacheKey);
9✔
459
        } else {
1✔
460
          long effectiveHardExpireAtMs = expireManager.computeHardExpireAt(effectiveHard);
5✔
461
          long effectiveSoftExpireAtMs = expireManager.computeSoftExpireAt(effectiveSoft);
5✔
462

463
          caffeineCache
2✔
464
            .asMap()
8✔
465
            .compute(cacheKey, (k, existing) -> {
2✔
466
              if (isWorkerManagedEntry(existing)) {
3✔
467
                return existing;
2✔
468
              }
469
              return CacheEntry.builder()
3✔
470
                .value(value)
2✔
471
                .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
472
                .isVersionDegraded(false)
2✔
473
                .decisionVersion(0L)
2✔
474
                .hardTtlMs(effectiveHard)
2✔
475
                .hardExpireAtMs(effectiveHardExpireAtMs)
2✔
476
                .softTtlMs(effectiveSoft)
2✔
477
                .softExpireAtMs(effectiveSoftExpireAtMs)
2✔
478
                .keyState(KeyState.NORMAL)
2✔
479
                .normalHardTtlMs(effectiveHard)
2✔
480
                .normalSoftTtlMs(effectiveSoft)
1✔
481
                .build();
1✔
482
            });
483

484
          if (!skipReport) {
2✔
485
            hotKeyReporter.ifPresent(r -> r.record(cacheKey));
9✔
486
          }
487
          log.debug("Normal key, cached with configured TTL: {}", cacheKey);
4✔
488
        }
489
        return value;
2✔
490
      });
491
  }
492

493
  /**
494
   * Promotes a non-hot entry to HOT state in L1 if the local TopK detector
495
   * now considers it a hot key. Preserves existing version and TTL metadata.
496
   * {@link KeyState#NORMAL} entries are always eligible; {@link KeyState#COOL}
497
   * entries are only eligible when all Workers are dead (graceful fallback).
498
   *
499
   * @param cacheKey  the key to promote
500
   * @param raw       the raw cached value (maybe a {@link CacheEntry} or a bare object)
501
   * @param val       the extracted value from the cache entry
502
   * @param hardTtlMs hard TTL override (0 = use configured hot hard TTL)
503
   * @param softTtlMs soft TTL override (0 = use configured hot soft TTL)
504
   */
505
  private boolean promoteLocalHotkeyIfNeeded(String cacheKey, Object raw, Object val, long hardTtlMs, long softTtlMs) {
506
    if (raw instanceof CacheEntry ce && isPromotableState(ce.getKeyState())) {
11✔
507
      hotKeyDetector.add(cacheKey, HotKeyConstants.TOPK_INCR);
5✔
508
      if (!hotKeyDetector.contains(cacheKey)) {
5✔
509
        return false;
2✔
510
      }
511
      long hotHard = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHotHardTtlMs();
8!
512
      long hotSoft = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveHotSoftTtlMs();
8!
513
      long hotHardExpireAtMs = expireManager.computeHardExpireAt(hotHard);
5✔
514
      long hotSoftExpireAtMs = expireManager.computeSoftExpireAt(hotSoft);
5✔
515

516
      caffeineCache
2✔
517
        .asMap()
10✔
518
        .compute(cacheKey, (k, existing) -> {
2✔
519
          if (existing instanceof CacheEntry entry) {
6!
520
            if (!isPromotableState(entry.getKeyState())) {
5!
521
              return existing;
×
522
            }
523

524
            return entry
2✔
525
              .toBuilder()
2✔
526
              .hardTtlMs(hotHard)
2✔
527
              .softTtlMs(hotSoft)
2✔
528
              .hardExpireAtMs(hotHardExpireAtMs)
2✔
529
              .softExpireAtMs(hotSoftExpireAtMs)
2✔
530
              .keyState(KeyState.HOT)
1✔
531
              .build();
1✔
532
          }
533

534
          return CacheEntry.builder()
×
535
            .value(val)
×
536
            .dataVersion(ce.getDataVersion())
×
537
            .isVersionDegraded(ce.isVersionDegraded())
×
538
            .decisionVersion(ce.getDecisionVersion())
×
539
            .hardTtlMs(hotHard)
×
540
            .hardExpireAtMs(hotHardExpireAtMs)
×
541
            .softTtlMs(hotSoft)
×
542
            .softExpireAtMs(hotSoftExpireAtMs)
×
543
            .keyState(KeyState.HOT)
×
544
            .normalHardTtlMs(ce.getNormalHardTtlMs())
×
545
            .normalSoftTtlMs(ce.getNormalSoftTtlMs())
×
546
            .build();
×
547
        });
548
      return true;
2✔
549
    }
550
    return false;
2✔
551
  }
552

553
  /**
554
   * Invalidate a single key from L1 and broadcast INVALIDATE to peers,
555
   * so they remove their local copy and re-fetch on next {@link #get}.
556
   * The next local {@link #get} will re-fetch from the reader.
557
   *
558
   * @param cacheKey the key to invalidate
559
   */
560
  public void invalidate(String cacheKey) {
561
    if (invalidCacheKey(cacheKey)) {
3✔
562
      log.debug("invalidate: invalid cacheKey");
3✔
563
      return;
1✔
564
    }
565

566
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
567
      var vr = versionController.nextVersion(cacheKey);
5✔
568
      caffeineCache.invalidate(cacheKey);
4✔
569
      cacheSyncPublisher.ifPresentOrElse(
7✔
570
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
571
        () -> log.debug("invalidate: " + NO_SYNC_PUBLISHER)
4✔
572
      );
573
    });
1✔
574
  }
1✔
575

576
  /**
577
   * Invalidate all given keys from L1 and broadcast INVALIDATE for each.
578
   * Invalid keys (null or blank) are silently skipped.
579
   *
580
   * @param cacheKeys the keys to invalidate
581
   */
582
  public void invalidateAll(Collection<String> cacheKeys) {
583
    List<String> validKeys = cacheKeys
1✔
584
      .stream()
2✔
585
      .filter(k -> !invalidCacheKey(k))
8✔
586
      .toList();
2✔
587
    if (validKeys.isEmpty()) {
3✔
588
      log.debug("invalidateAllLocal: all cacheKeys are invalid");
3✔
589
      return;
1✔
590
    }
591

592
    TransactionSupport.runNowOrAfterCommit(() -> {
4✔
593
      caffeineCache.invalidateAll(validKeys);
4✔
594
      cacheSyncPublisher.ifPresentOrElse(
7✔
595
        p -> p.broadcastLocalInvalidateAll(validKeys),
4✔
596
        () -> log.debug("No sync publisher found, skip broadcast for {} keys", validKeys.size())
7✔
597
      );
598
    });
1✔
599
  }
1✔
600

601
  /**
602
   * Evict keys from the local cache only, without broadcasting to other
603
   * instances and without bumping version numbers.
604
   *
605
   * <p>Useful for emergency local cleanup, testing, or when a module is
606
   * taken offline and only the current node needs to be cleared.
607
   *
608
   * @param cacheKeys the keys to evict locally
609
   */
610
  public void evictLocal(Collection<String> cacheKeys) {
611
    List<String> validKeys = cacheKeys
1✔
612
      .stream()
2✔
613
      .filter(k -> !invalidCacheKey(k))
8✔
614
      .toList();
2✔
615
    if (validKeys.isEmpty()) {
3✔
616
      log.debug("evictLocal: all cacheKeys are invalid");
3✔
617
      return;
1✔
618
    }
619
    caffeineCache.invalidateAll(validKeys);
4✔
620
    log.debug("evictLocal: evicted {} keys locally", validKeys.size());
6✔
621
  }
1✔
622

623
  /**
624
   * Write-through: execute the writer, then update L1 and broadcast.
625
   * Uses effective hard/soft TTL from configuration.
626
   *
627
   * @param cacheKey the key to write
628
   * @param value    the value to cache
629
   * @param writer   the data-source mutation to execute before caching
630
   * @param <T>      the value type
631
   * @throws HotKeyBlockedException when the key matches a blacklist rule
632
   */
633
  public <T> void putThrough(String cacheKey, T value, Runnable writer) {
634
    putThrough(cacheKey, value, writer, 0L, 0L);
7✔
635
  }
1✔
636

637
  /**
638
   * Write-through with explicit TTL overrides.
639
   * Pass 0 to use the configured default for that TTL type.
640
   *
641
   * @param cacheKey  the key to write
642
   * @param value     the value to cache
643
   * @param writer    the data-source mutation to execute before caching
644
   * @param hardTtlMs hard TTL override (0 = use configured default; {@link Long#MAX_VALUE} for permanent entry — no hard TTL eviction)
645
   * @param softTtlMs soft TTL override (0 = use configured default)
646
   * @param <T>       the value type
647
   */
648
  public <T> void putThrough(String cacheKey, T value, Runnable writer, long hardTtlMs, long softTtlMs) {
649
    if (invalidCacheKey(cacheKey)) {
3✔
650
      log.debug("putThrough: invalid cacheKey");
3✔
651
      return;
1✔
652
    }
653
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
654
      log.debug("putThrough: blocked by rule: {}", cacheKey);
4✔
655
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
656
    }
657
    TransactionSupport.runAsyncAfterCommit(
10✔
658
      () -> {
659
        try {
660
          writer.run();
2✔
661
        } catch (Exception e) {
×
662
          log.error("putThrough writer failed for key={}, cache update skipped: {}", cacheKey, e.getMessage(), e);
×
663
          return;
×
664
        }
1✔
665
        var vr = versionController.nextVersion(cacheKey);
5✔
666

667
        long effectiveHardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
668
        long effectiveSoftTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
669

670
        caffeineCache
2✔
671
          .asMap()
8✔
672
          .compute(cacheKey, (k, existing) ->
2✔
673
            buildPutThroughEntry(existing, value, vr, effectiveHardTtl, effectiveSoftTtl)
8✔
674
          );
675

676
        cacheSyncPublisher.ifPresentOrElse(
7✔
677
          p -> p.broadcastRefresh(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
678
          () -> log.debug("putThrough: {}", NO_SYNC_PUBLISHER)
5✔
679
        );
680
      },
1✔
681
      hotKeyExecutor
682
    );
683
  }
1✔
684

685
  /**
686
   * Builds a {@link CacheEntry} for a write-through operation, preserving
687
   * Worker-managed state and version information from the existing entry.
688
   *
689
   * @param existing          the previous cache entry (maybe {@code null} or a raw value)
690
   * @param value             the new value to cache
691
   * @param vr                the version result from the version controller
692
   * @param effectiveHardTtl  the effective hard TTL to use
693
   * @param effectiveSoftTtl  the effective soft TTL to use
694
   * @param <T>               the value type
695
   * @return a new {@link CacheEntry} with the supplied value and metadata
696
   */
697
  private <T> CacheEntry buildPutThroughEntry(
698
    Object existing,
699
    T value,
700
    VersionController.VersionResult vr,
701
    long effectiveHardTtl,
702
    long effectiveSoftTtl
703
  ) {
704
    KeyState state = (existing instanceof CacheEntry entry) ? entry.getKeyState() : KeyState.NORMAL;
11✔
705
    if (existing instanceof CacheEntry ce && VersionGuard.shouldSkipForSync(ce, vr.dataVersion(), vr.degraded())) {
13✔
706
      return ce;
2✔
707
    }
708

709
    boolean isWorkerManaged = isWorkerManagedEntry(existing);
3✔
710

711
    long normalHardTtl = 0;
2✔
712
    long normalSoftTtl = 0;
2✔
713

714
    long decisionVersion = (existing instanceof CacheEntry entry) ? entry.getDecisionVersion() : 0L;
11✔
715
    String decisionNodeId = (existing instanceof CacheEntry entry) ? entry.getDecisionNodeId() : null;
11✔
716
    long decisionEpoch = (existing instanceof CacheEntry entry) ? entry.getDecisionEpoch() : 0L;
11✔
717

718
    if (existing instanceof CacheEntry) {
3✔
719
      normalHardTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalHardTtlMs() : effectiveHardTtl;
7!
720
    }
721
    if (existing instanceof CacheEntry) {
3✔
722
      normalSoftTtl = isWorkerManaged ? ((CacheEntry) existing).getNormalSoftTtlMs() : effectiveSoftTtl;
7!
723
    }
724

725
    return CacheEntry.builder()
2✔
726
      .value(value != null ? value : NullValue.INSTANCE)
7✔
727
      .dataVersion(vr.dataVersion())
3✔
728
      .isVersionDegraded(vr.degraded())
3✔
729
      .decisionVersion(decisionVersion)
2✔
730
      .decisionNodeId(decisionNodeId)
2✔
731
      .decisionEpoch(decisionEpoch)
1✔
732
      .hardTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotHardTtlMs() : effectiveHardTtl)
9✔
733
      .hardExpireAtMs(
1✔
734
        state == KeyState.HOT
3✔
735
          ? expireManager.computeHotHardExpireAt()
4✔
736
          : expireManager.computeHardExpireAt(effectiveHardTtl)
4✔
737
      )
738
      .softTtlMs(state == KeyState.HOT ? expireManager.getEffectiveHotSoftTtlMs() : effectiveSoftTtl)
9✔
739
      .softExpireAtMs(
2✔
740
        state == KeyState.HOT
3✔
741
          ? expireManager.computeHotSoftExpireAt()
4✔
742
          : expireManager.computeSoftExpireAt(effectiveSoftTtl)
4✔
743
      )
744
      .keyState(state)
2✔
745
      .normalHardTtlMs(normalHardTtl)
2✔
746
      .normalSoftTtlMs(normalSoftTtl)
1✔
747
      .build();
1✔
748
  }
749

750
  /**
751
   * Write a value directly into the local L1 cache without version bump,
752
   * without broadcast, without hot-key detection, and without reporting.
753
   * <p>
754
   * Existing entry metadata is preserved.  If no entry exists, a fresh
755
   * {@link CacheEntry} is created with {@link KeyState#NORMAL}.
756
   * <p>
757
   * Pass {@code 0} for either TTL to use the configured default.
758
   *
759
   * @param cacheKey  the key to store
760
   * @param value     the value to cache
761
   * @param hardTtlMs hard TTL override (0 = use configured default)
762
   * @param softTtlMs soft TTL override (0 = use configured default)
763
   */
764
  public void putLocal(String cacheKey, Object value, long hardTtlMs, long softTtlMs) {
765
    if (invalidCacheKey(cacheKey)) {
3✔
766
      log.debug("putLocal: invalid cacheKey");
3✔
767
      return;
1✔
768
    }
769
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
770
      log.debug("putLocal: blocked by rule: {}", cacheKey);
4✔
771
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
772
    }
773

774
    long hardTtl = hardTtlMs > 0 ? hardTtlMs : expireManager.getEffectiveHardTtlMs();
10✔
775
    long softTtl = softTtlMs > 0 ? softTtlMs : expireManager.getEffectiveSoftTtlMs();
10✔
776

777
    caffeineCache
2✔
778
      .asMap()
7✔
779
      .compute(cacheKey, (k, existing) -> {
2✔
780
        if (existing instanceof CacheEntry ce) {
6✔
781
          return ce
2✔
782
            .toBuilder()
2✔
783
            .value(value)
2✔
784
            .hardTtlMs(hardTtl)
2✔
785
            .softTtlMs(softTtl)
4✔
786
            .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
5✔
787
            .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
2✔
788
            .build();
1✔
789
        }
790
        return CacheEntry.builder()
3✔
791
          .value(value)
2✔
792
          .dataVersion(HotKeyConstants.VERSION_DEFAULT)
2✔
793
          .isVersionDegraded(false)
2✔
794
          .decisionVersion(0L)
2✔
795
          .hardTtlMs(hardTtl)
4✔
796
          .hardExpireAtMs(expireManager.computeHardExpireAt(hardTtl))
3✔
797
          .softTtlMs(softTtl)
4✔
798
          .softExpireAtMs(expireManager.computeSoftExpireAt(softTtl))
3✔
799
          .keyState(KeyState.NORMAL)
2✔
800
          .normalHardTtlMs(hardTtl)
2✔
801
          .normalSoftTtlMs(softTtl)
1✔
802
          .build();
1✔
803
      });
804
  }
1✔
805

806
  /**
807
   * Execute a mutation, then invalidate L1 and broadcast.
808
   * Next {@link #get} will re-fetch from the reader.
809
   *
810
   * @param cacheKey the key to invalidate after mutation
811
   * @param mutation the mutation to execute
812
   */
813
  public void putBeforeInvalidate(String cacheKey, Runnable mutation) {
814
    if (invalidCacheKey(cacheKey)) {
3✔
815
      log.debug("putBeforeInvalidate: invalid cacheKey");
3✔
816
      return;
1✔
817
    }
818
    if (ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK) {
6✔
819
      log.debug("putBeforeInvalidate: blocked by rule: {}", cacheKey);
4✔
820
      throw new HotKeyBlockedException("HotKeyCache", cacheKey);
6✔
821
    }
822
    TransactionSupport.runNowOrAfterCommit(() -> {
5✔
823
      try {
824
        mutation.run();
2✔
825
      } catch (Exception e) {
1✔
826
        log.error("putBeforeInvalidate failed, skip local invalidate and broadcast: {}", cacheKey, e);
5✔
827
        return;
1✔
828
      }
1✔
829
      var vr = versionController.nextVersion(cacheKey);
5✔
830
      caffeineCache.invalidate(cacheKey);
4✔
831

832
      cacheSyncPublisher.ifPresentOrElse(
7✔
833
        p -> p.broadcastLocalInvalidate(cacheKey, vr.dataVersion(), vr.degraded()),
8✔
834
        () -> log.debug("putBeforeInvalidate: {}", NO_SYNC_PUBLISHER)
5✔
835
      );
836
    });
1✔
837
  }
1✔
838

839
  /**
840
   * Add a key pattern to the blacklist.
841
   * Subsequent accesses to matching keys will throw {@link HotKeyBlockedException}.
842
   *
843
   * @param cacheKey the key pattern to blacklist
844
   */
845
  public void addBlacklist(String cacheKey) {
846
    if (invalidCacheKey(cacheKey)) {
3✔
847
      log.debug("blacklist: invalid cacheKey");
3✔
848
      return;
1✔
849
    }
850
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.BLOCK)));
11✔
851
  }
1✔
852

853
  /**
854
   * Add a key pattern to the whitelist.
855
   * Matching keys are allowed but bypass app-to-Worker reporting.
856
   *
857
   * @param cacheKey the key pattern to whitelist
858
   */
859
  public void addWhitelist(String cacheKey) {
860
    if (invalidCacheKey(cacheKey)) {
3✔
861
      log.debug("whitelist: invalid cacheKey");
3✔
862
      return;
1✔
863
    }
864
    TransactionSupport.runNowOrAfterCommit(() ->
4✔
865
      ruleMatcher.addRule(RuleMatcher.of(cacheKey, RuleAction.ALLOW_NO_REPORT))
7✔
866
    );
867
  }
1✔
868

869
  /**
870
   * Remove a key pattern from the blacklist.
871
   *
872
   * @param cacheKey the key pattern to remove from the blacklist
873
   */
874
  public void unBlacklist(String cacheKey) {
875
    if (invalidCacheKey(cacheKey)) {
3✔
876
      log.debug("unblacklist: invalid cacheKey");
3✔
877
      return;
1✔
878
    }
879
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.BLOCK));
11✔
880
  }
1✔
881

882
  /**
883
   * Remove a key pattern from the whitelist.
884
   *
885
   * @param cacheKey the key pattern to remove from the whitelist
886
   */
887
  public void unWhitelist(String cacheKey) {
888
    if (invalidCacheKey(cacheKey)) {
3✔
889
      log.debug("unwhitelist: invalid cacheKey");
3✔
890
      return;
1✔
891
    }
892
    TransactionSupport.runNowOrAfterCommit(() -> ruleMatcher.removeRule(cacheKey, RuleAction.ALLOW_NO_REPORT));
11✔
893
  }
1✔
894

895
  /**
896
   * Evaluate all rules against the given key and return the first matching action.
897
   *
898
   * @param cacheKey the key to evaluate
899
   * @return the matching {@link RuleAction}, or {@code ALLOW} if no rule matches
900
   */
901
  public RuleAction evaluateRule(String cacheKey) {
902
    return ruleMatcher.evaluateRule(cacheKey);
5✔
903
  }
904

905
  /**
906
   * Return a snapshot of all current rules in evaluation order.
907
   *
908
   * @return list of rules (immutable snapshot)
909
   */
910
  public List<Rule> getAllRules() {
911
    return ruleMatcher.getAllRules();
4✔
912
  }
913

914
  /**
915
   * Remove all blacklist and whitelist rules.
916
   */
917
  public void clearAllRules() {
918
    TransactionSupport.runNowOrAfterCommit(ruleMatcher::clearRules);
7✔
919
  }
1✔
920

921
  /**
922
   * Broadcast all local rules to peer instances via the sync exchange.
923
   * Useful for initial synchronization when a new instance joins the cluster.
924
   */
925
  public void broadcastAllLocalRulesManually() {
926
    ruleMatcher.broadcastAllLocalRulesManually();
3✔
927
  }
1✔
928

929
  /**
930
   * Estimated number of entries currently in the L1 cache.
931
   *
932
   * @return best-effort estimate of the current entry count
933
   */
934
  public long estimatedSize() {
935
    return caffeineCache.estimatedSize();
4✔
936
  }
937

938
  /**
939
   * Return a snapshot of basic L1 cache statistics.
940
   * <p>
941
   * Hit/miss/eviction counters are populated only when Caffeine's
942
   * {@code recordStats()} is enabled.  {@code estimatedSize} is always
943
   * available.
944
   *
945
   * @return a {@link HotKeyCacheStats} record; hit/miss counters are {@code 0}
946
   *         if stats recording is not enabled
947
   */
948
  public HotKeyCacheStats stats() {
949
    CacheStats cs = caffeineCache.stats();
4✔
950
    return new HotKeyCacheStats(
4✔
951
      cs.hitCount(),
2✔
952
      cs.missCount(),
2✔
953
      cs.hitRate(),
2✔
954
      cs.evictionCount(),
3✔
955
      caffeineCache.estimatedSize()
2✔
956
    );
957
  }
958

959
  /**
960
   * Check whether the given key is blacklisted.
961
   *
962
   * @param cacheKey the key to check
963
   * @return {@code true} if a blacklist rule matches the key
964
   */
965

966
  public boolean isBlacklisted(String cacheKey) {
967
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.BLOCK;
10✔
968
  }
969

970
  /**
971
   * Check whether the given key is whitelisted (skips Worker reporting).
972
   *
973
   * @param cacheKey the key to check
974
   * @return {@code true} if a whitelist rule matches the key
975
   */
976
  public boolean isWhitelisted(String cacheKey) {
977
    return ruleMatcher.evaluateRule(cacheKey) == RuleAction.ALLOW_NO_REPORT;
10✔
978
  }
979

980
  /**
981
   * Invalidate all entries from the L1 cache without broadcasting.
982
   * <p>
983
   * This is an emergency flush — all cached values are removed immediately.
984
   * No cross-instance sync messages are sent.
985
   */
986
  public void invalidateAllLocal() {
987
    caffeineCache.invalidateAll();
3✔
988
  }
1✔
989

990
  /**
991
   * Return the underlying Caffeine cache for direct access.
992
   *
993
   * <p>This provides access to Caffeine-specific operations such as
994
   * {@code asMap()}, {@code policy()}, and {@code cleanUp()}. Use with
995
   * caution — bypassing the HotKey orchestration layer (version tracking,
996
   * broadcast, expiry management) can lead to inconsistent state.
997
   *
998
   * @return the raw Caffeine {@link Cache} instance
999
   */
1000
  public Cache<String, Object> getLocalCache() {
1001
    return caffeineCache;
3✔
1002
  }
1003

1004
  /**
1005
   * Unwrap a {@link NullValue} sentinel back to {@code null}.
1006
   * <p>
1007
   * When called from the extraction paths of {@link #get}, {@link #getWithSoftExpire},
1008
   * and {@link #peek}, this ensures that null values stored via the sentinel are
1009
   * transparently returned as {@code null} to callers.
1010
   *
1011
   * @param value the raw value from a {@link CacheEntry}
1012
   * @return {@code null} if the sentinel, otherwise the original value
1013
   */
1014
  @Nullable
1015
  private static Object unwrapNull(@Nullable Object value) {
1016
    return value == NullValue.INSTANCE ? null : value;
7✔
1017
  }
1018
}
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