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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

64.62
/common/src/main/java/io/github/hyshmily/hotkey/autoconfigure/HotKeyAutoConfiguration.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.autoconfigure;
17

18
import static io.github.hyshmily.hotkey.util.TimeSource.currentTimeMillis;
19

20
import com.github.benmanes.caffeine.cache.Cache;
21
import com.github.benmanes.caffeine.cache.Caffeine;
22
import com.github.benmanes.caffeine.cache.Expiry;
23
import io.github.hyshmily.hotkey.HotKey;
24
import io.github.hyshmily.hotkey.Internal;
25
import io.github.hyshmily.hotkey.cache.HotKeyCache;
26
import io.github.hyshmily.hotkey.cache.cachesupport.CacheExpireManager;
27
import io.github.hyshmily.hotkey.cache.cachesupport.HotKeyCircuitBreaker;
28
import io.github.hyshmily.hotkey.cache.cachesupport.SingleFlight;
29
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
30
import io.github.hyshmily.hotkey.hotkeydetector.HotKeyDetector;
31
import io.github.hyshmily.hotkey.hotkeydetector.heavykeeper.HeavyKeeper;
32
import io.github.hyshmily.hotkey.hotkeydetector.heavykeeper.TopK;
33
import io.github.hyshmily.hotkey.model.CacheEntry;
34
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
35
import io.github.hyshmily.hotkey.rule.RuleMatcher;
36
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
37
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
38
import io.github.hyshmily.hotkey.util.version.VersionController;
39
import java.util.Optional;
40
import java.util.concurrent.Executor;
41
import java.util.concurrent.RejectedExecutionException;
42
import java.util.concurrent.ScheduledExecutorService;
43
import java.util.concurrent.TimeUnit;
44
import lombok.extern.slf4j.Slf4j;
45
import org.jspecify.annotations.NonNull;
46
import org.springframework.beans.factory.ObjectProvider;
47
import org.springframework.beans.factory.annotation.Qualifier;
48
import org.springframework.boot.autoconfigure.AutoConfiguration;
49
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
50
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
51
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
52
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
53
import org.springframework.boot.context.properties.EnableConfigurationProperties;
54
import org.springframework.context.annotation.Bean;
55
import org.springframework.data.redis.core.StringRedisTemplate;
56
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
57

58
/**
59
 * App-side autoconfiguration for the HotKey library.
60
 *
61
 * <p>Creates the app-side {@link TopK} detector (HeavyKeeper), L1 Caffeine
62
 * cache, {@link SingleFlight} deduplication layer, executor, and the
63
 * primary {@link HotKeyCache} (without Redis version tracking when Redis
64
 * is absent).
65
 *
66
 * <p><b>Condition:</b> this configuration is <em>skipped</em> when the
67
 * Worker is active ({@code hotkey.worker.enabled=true}). It runs when
68
 * Worker is disabled or the property is absent.
69
 */
70
@Internal
71
@AutoConfiguration(after = RedisAutoConfiguration.class)
72
@ConditionalOnProperty(prefix = "hotkey.worker", name = "enabled", havingValue = "false", matchIfMissing = true)
73
@EnableConfigurationProperties(HotKeyProperties.class)
74
@Slf4j
4✔
75
public class HotKeyAutoConfiguration {
3✔
76

77
  /**
78
   * Create the app-side TopK instance (HeavyKeeper) as a standalone bean.
79
   *
80
   * <p>The HeavyKeeper uses a Count-Min Sketch augmented with a minimum-count
81
   * threshold and exponential decay to track the top-K hottest keys with high
82
   * accuracy and low memory footprint. Configuration parameters (width, depth,
83
   * decay, minCount, topK capacity, expelled queue capacity) are read from
84
   * {@link HotKeyProperties}.
85
   *
86
   * @param properties the HotKey configuration properties (never {@code null})
87
   * @return a new HeavyKeeper TopK instance
88
   */
89
  @Bean
90
  @ConditionalOnMissingBean
91
  public HeavyKeeper heavyKeeper(HotKeyProperties properties) {
92
    return new HeavyKeeper(
4✔
93
      properties.getTopK(),
2✔
94
      properties.getWidth(),
2✔
95
      properties.getDepth(),
2✔
96
      properties.getDecay(),
2✔
97
      properties.getMinCount(),
2✔
98
      properties.getExpelledQueueCapacity(),
2✔
99
      properties.getSketchWindowCount()
2✔
100
    );
101
  }
102

103
  /**
104
   * Create the app-side {@link HotKeyDetector} facade that wraps the HeavyKeeper TopK
105
   * and schedules periodic decay.
106
   *
107
   * <p>The detector provides the primary hot-key detection API ({@code add()}, {@code list()},
108
   * {@code total()}) and schedules the periodic HeavyKeeper decay using the shared scheduler.
109
   * This bean is the {@code @Qualifier("hotKeyDetector")} target for injection into
110
   * {@link HotKeyCache} and other components.
111
   *
112
   * @param heavyKeeper      the HeavyKeeper TopK instance (never {@code null})
113
   * @param hotKeyScheduler  the shared scheduler for periodic tasks (never {@code null})
114
   * @return a new HotKeyDetector instance
115
   */
116
  @Bean
117
  @ConditionalOnMissingBean
118
  @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
119
  public HotKeyDetector hotKeyDetector(
120
    HeavyKeeper heavyKeeper,
121
    @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
122
  ) {
123
    return new HotKeyDetector(heavyKeeper, hotKeyScheduler);
6✔
124
  }
125

126
  /**
127
   * Create the SingleFlight deduplication layer for concurrent cache-load requests.
128
   *
129
   * <p>When multiple threads request the same key simultaneously (a cache miss), the
130
   * first caller triggers the load and subsequent callers wait for the same result
131
   * rather than duplicating the load. Configuration parameters (max in-flight entries,
132
   * TTL, timeout) are read from {@link HotKeyProperties}.
133
   *
134
   * @param properties     the HotKey configuration properties (never {@code null})
135
   * @param hotKeyExecutor the dedicated HotKey executor for async load execution (never {@code null})
136
   * @return a new SingleFlight instance
137
   */
138
  @Bean
139
  @ConditionalOnMissingBean
140
  public SingleFlight singleFlight(HotKeyProperties properties, @Qualifier("hotKeyExecutor") Executor hotKeyExecutor) {
141
    return new SingleFlight(
4✔
142
      properties.getInflightMaxSize(),
2✔
143
      properties.getInflightTtlSeconds(),
2✔
144
      properties.getInflightTimeoutSeconds(),
5✔
145
      hotKeyExecutor,
146
      new HotKeyCircuitBreaker(properties.getCircuitBreaker())
3✔
147
    );
148
  }
149

150
  /**
151
   * Create the soft/hard expiration manager that manages time-based eviction.
152
   *
153
   * <p>The soft-expire mechanism triggers an asynchronous refresh when a configurable
154
   * portion of the TTL has elapsed, serving stale data while fetching a fresh value
155
   * in the background. The hard-expire is the absolute maximum TTL enforced at the
156
   * Caffeine level. The refresh pool size limits concurrent background refresh tasks
157
   * to prevent resource exhaustion under high cache-miss rates.
158
   *
159
   * @param hotLocalCache  the L1 Caffeine cache (never {@code null})
160
   * @param hotKeyExecutor the dedicated HotKey executor for async refresh tasks (never {@code null})
161
   * @param properties     the HotKey configuration properties (never {@code null})
162
   * @return a new CacheExpireManager instance
163
   */
164
  @Bean
165
  @ConditionalOnMissingBean
166
  public CacheExpireManager expireManager(
167
    Cache<String, Object> hotLocalCache,
168
    @Qualifier("hotKeyExecutor") Executor hotKeyExecutor,
169
    HotKeyProperties properties
170
  ) {
171
    return new CacheExpireManager(hotLocalCache, hotKeyExecutor, properties, properties.getRefreshMaxPools());
9✔
172
  }
173

174
  /**
175
   * Create the dedicated thread-pool executor for asynchronous cache operations.
176
   *
177
   * <p>This executor handles all async operations in the HotKey data path: cache loading
178
   * via SingleFlight, soft-expiry refresh tasks, and cross-instance broadcast callbacks.
179
   * Uses a bounded thread pool to limit concurrent AMQP channel usage (RabbitMQ's
180
   * {@code CachingConnectionFactory} associates channels with platform threads, so
181
   * unbounded virtual-thread concurrency causes channel-open timeouts). The pool is
182
   * configured with core/max pool size, bounded queue capacity, and a rejection policy
183
   * that throws {@link RejectedExecutionException} when the queue is full. On shutdown,
184
   * in-progress tasks are allowed to complete with a 60-second grace period.
185
   *
186
   * @param properties the HotKey configuration properties (never {@code null})
187
   * @return a configured {@link ThreadPoolTaskExecutor}
188
   */
189
  @Bean("hotKeyExecutor")
190
  @ConditionalOnMissingBean(name = "hotKeyExecutor")
191
  public Executor hotKeyExecutor(HotKeyProperties properties) {
192
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
4✔
193
    executor.setCorePoolSize(properties.getExecutorCorePoolSize());
4✔
194
    executor.setMaxPoolSize(properties.getExecutorMaxPoolSize());
4✔
195
    executor.setQueueCapacity(properties.getExecutorQueueCapacity());
4✔
196
    executor.setThreadNamePrefix(HotKeyConstants.THREAD_PREFIX_HOTKEY);
3✔
197
    executor.setWaitForTasksToCompleteOnShutdown(true);
3✔
198
    executor.setAwaitTerminationSeconds(60);
3✔
199
    executor.setRejectedExecutionHandler((r, exe) -> {
4✔
200
      log.warn(
×
201
        "HotKey executor task rejected: corePool={}, maxPool={}, queueCapacity={}",
202
        properties.getExecutorCorePoolSize(),
×
203
        properties.getExecutorMaxPoolSize(),
×
204
        properties.getExecutorQueueCapacity()
×
205
      );
206
      throw new RejectedExecutionException("HotKey executor queue full");
×
207
    });
208
    executor.initialize();
2✔
209
    return executor;
2✔
210
  }
211

212
  /**
213
   * Create the {@link RuleMatcher} for key matching against user-defined rules.
214
   *
215
   * <p>This variant is wired with an empty Redis provider and an optional sync
216
   * publisher, since no {@link StringRedisTemplate} is available in the non-Redis
217
   * deployment mode. Rules are kept in-memory only and do not survive restarts.
218
   * When Redis is available, {@link HotKeyRedisAutoConfiguration#ruleMatcher}
219
   * takes over with Redis persistence.
220
   *
221
   * @param publisherProvider optional provider for the cache sync publisher (may be absent)
222
   * @return a new RuleMatcher instance (in-memory only)
223
   */
224
  @Bean
225
  @ConditionalOnMissingBean({ RuleMatcher.class, StringRedisTemplate.class })
226
  public RuleMatcher ruleMatcher(ObjectProvider<CacheSyncPublisher> publisherProvider) {
227
    return new RuleMatcher(Optional.empty(), Optional.ofNullable(publisherProvider.getIfAvailable()));
9✔
228
  }
229

230
  /**
231
   * Create the {@link HotKeyCache} (non-Redis variant).
232
   *
233
   * <p>Only active when {@code RedisTemplate} is absent; otherwise
234
   * {@link HotKeyRedisAutoConfiguration#hotKeyCache} takes over. Creates a
235
   * {@link VersionController} with an empty Redis provider (falling back to
236
   * node-local counters) and default ring manager / health view when none are
237
   * available from the application context.
238
   *
239
   * @param hotKeyDetector            the app-side TopK detector (never {@code null})
240
   * @param hotLocalCache             the L1 Caffeine cache (never {@code null})
241
   * @param singleFlight              the deduplication layer (never {@code null})
242
   * @param expireManager             the soft/hard expiration manager (never {@code null})
243
   * @param syncPublisher             optional cache sync publisher (may be absent)
244
   * @param hotKeyReporter            optional hot key reporter (may be absent; e.g. in Worker-only mode)
245
   * @param hotKeyExecutor            the dedicated HotKey executor (never {@code null})
246
   * @param properties                the HotKey configuration properties (never {@code null})
247
   * @param ruleMatcher               the rule matcher instance (never {@code null})
248
   * @param healthViewProvider        provider for the cluster health view (creates default if absent)
249
   * @return a new HotKeyCache instance with node-local version tracking
250
   */
251
  @Bean
252
  @ConditionalOnMissingBean(type = "org.springframework.data.redis.core.RedisTemplate")
253
  public HotKeyCache hotKeyCache(
254
    @Qualifier("hotKeyDetector") HotKeyDetector hotKeyDetector,
255
    Cache<String, Object> hotLocalCache,
256
    SingleFlight singleFlight,
257
    CacheExpireManager expireManager,
258
    Optional<CacheSyncPublisher> syncPublisher,
259
    Optional<HotKeyReporter> hotKeyReporter,
260
    @Qualifier("hotKeyExecutor") Executor hotKeyExecutor,
261
    HotKeyProperties properties,
262
    RuleMatcher ruleMatcher,
263
    ObjectProvider<ClusterHealthView> healthViewProvider
264
  ) {
265
    return new HotKeyCache(
13✔
266
      hotKeyDetector,
267
      hotLocalCache,
268
      singleFlight,
269
      expireManager,
270
      hotKeyExecutor,
271
      syncPublisher,
272
      hotKeyReporter,
273
      ruleMatcher,
274
      new VersionController(Optional.empty(), properties.getVersionKeyTtlMinutes()),
8✔
275
      properties,
276
      new ClusterHealthView(
277
        properties.getExpectedWorkerCount(),
2✔
278
        properties.getHeartbeat().getTimeoutMs(),
4✔
279
        properties.getHeartbeat().getDegradeAfterFailures()
4✔
280
      )
281
    );
282
  }
283

284
  /**
285
   * Fallback {@link HotKey} facade bean.
286
   *
287
   * <p>Only active when a {@link HotKeyCache} bean exists but no {@link HotKey}
288
   * has been defined yet. The primary {@link HotKey} creator is
289
   * {@link HotKeyFacadeAutoConfiguration} — this fallback covers the case where
290
   * that auto-configuration is excluded or its bean is overridden.
291
   *
292
   * @param hotKeyCache    the HotKeyCache instance (never {@code null})
293
   * @param appHotKeyDetector the app-side TopK detector (never {@code null})
294
   * @param workerTopKProvider     the Worker-side TopK algorithm (may be {@code null})
295
   * @return a new HotKey facade instance
296
   */
297
  @Bean
298
  @ConditionalOnBean(HotKeyCache.class)
299
  @ConditionalOnMissingBean
300
  public HotKey hotKey(
301
    HotKeyCache hotKeyCache,
302
    @Qualifier("hotKeyDetector") HotKeyDetector appHotKeyDetector,
303
    @Qualifier("workerTopK") ObjectProvider<TopK> workerTopKProvider
304
  ) {
305
    return new HotKey(hotKeyCache, appHotKeyDetector, workerTopKProvider.getIfAvailable());
9✔
306
  }
307

308
  /**
309
   * Create the L1 Caffeine cache instance.
310
   *
311
   * <p>Time-based expiry operates at the <em>Caffeine</em> level via a custom
312
   * {@link Expiry} implementation, computing remaining nanoseconds from
313
   * . Entries with
314
   * {@code hardExpireAtMs == Long.MAX_VALUE} are purely logical-expiry
315
   * — Caffeine never evicts them by time; they live until size eviction or
316
   * manual invalidation. Reads never extend the expiry duration (no read-based
317
   * refresh), ensuring predictable TTL behavior.
318
   *
319
   * <p>The maximum cache size is configured via {@code hotkey.local.local-cache-max-size}
320
   * (default 1000 entries). Time-based TTL for entries without an explicit hard-expire
321
   * timestamp defaults to {@code hotkey.local.local-cache-ttl-minutes} (default 5 minutes).
322
   *
323
   * @param properties the HotKey configuration properties (never {@code null})
324
   * @return a configured Caffeine {@link Cache} instance
325
   */
326
  @Bean
327
  @ConditionalOnMissingBean
328
  public Cache<String, Object> hotLocalCache(HotKeyProperties properties) {
329
    Caffeine<Object, Object> builder = Caffeine.newBuilder()
2✔
330
      .maximumSize(properties.getLocalCacheMaxSize())
8✔
331
      .expireAfter(
2✔
332
        new Expiry<>() {
9✔
333
          /**
334
           * Compute the time-to-live for a newly created cache entry.
335
           * Returns {@link Long#MAX_VALUE} for pure logical-expiry entries
336
           * (where {@code hardExpireAtMs == Long.MAX_VALUE}); otherwise
337
           * computes the remaining wall-clock time.
338
           *
339
           * @param key              the cache key
340
           * @param value            the cache value (expected to be a {@link CacheEntry})
341
           * @param currentTimeNanos the current time in nanoseconds (provided by Caffeine)
342
           * @return the expiry duration in nanoseconds, or {@link Long#MAX_VALUE} for no expiry
343
           */
344
          @Override
345
          public long expireAfterCreate(@NonNull Object key, @NonNull Object value, long currentTimeNanos) {
346
            if (value instanceof CacheEntry entry) {
3!
347
              if (entry.getHardExpireAtMs() == Long.MAX_VALUE) {
×
348
                // Pure logical expiry: Caffeine never time-evicts this entry.
349
                // See Expiry Javadoc: Long.MAX_VALUE signals "no expiration".
350
                return Long.MAX_VALUE;
×
351
              }
352
              long remainingMs = entry.getHardExpireAtMs() - currentTimeMillis();
×
353
              return TimeUnit.MILLISECONDS.toNanos(Math.max(1, remainingMs));
×
354
            }
355
            return TimeUnit.MINUTES.toNanos(properties.getLocalCacheTtlMinutes());
7✔
356
          }
357

358
          /**
359
           * Re-compute the expiry duration after an entry is updated.
360
           * Preserves pure logical expiry across updates; otherwise
361
           * recalculates from the entry's {@code hardExpireAtMs}.
362
           *
363
           * @param key              the cache key
364
           * @param value            the cache value (expected to be a {@link CacheEntry})
365
           * @param currentTimeNanos the current time in nanoseconds (provided by Caffeine)
366
           * @param currentDuration  the current expiry duration in nanoseconds
367
           * @return the updated expiry duration in nanoseconds, or {@link Long#MAX_VALUE} for no expiry
368
           */
369
          @Override
370
          public long expireAfterUpdate(
371
            @NonNull Object key,
372
            @NonNull Object value,
373
            long currentTimeNanos,
374
            long currentDuration
375
          ) {
376
            if (value instanceof CacheEntry entry) {
×
377
              if (entry.getHardExpireAtMs() == Long.MAX_VALUE) {
×
378
                // Preserve pure logical expiry across updates (e.g. broadcast refresh).
379
                return Long.MAX_VALUE;
×
380
              }
381
              long remainingMs = entry.getHardExpireAtMs() - currentTimeMillis();
×
382
              return TimeUnit.MILLISECONDS.toNanos(Math.max(1, remainingMs));
×
383
            }
384
            return currentDuration;
×
385
          }
386

387
          /**
388
           * Preserve the current expiry duration on read — reads never
389
           * extend or shorten the entry's time-to-live.
390
           *
391
           * @param key              the cache key
392
           * @param value            the cache value
393
           * @param currentTimeNanos the current time in nanoseconds (provided by Caffeine)
394
           * @param currentDuration  the current expiry duration in nanoseconds
395
           * @return the unchanged expiry duration in nanoseconds
396
           */
397
          @Override
398
          public long expireAfterRead(
399
            @NonNull Object key,
400
            @NonNull Object value,
401
            long currentTimeNanos,
402
            long currentDuration
403
          ) {
404
            return currentDuration;
×
405
          }
406
        }
407
      );
408
    return builder.build();
3✔
409
  }
410
}
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