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

Hyshmily / hotkey / 28356107387

29 Jun 2026 07:35AM UTC coverage: 91.168% (-0.01%) from 91.178%
28356107387

push

github

Hyshmily
test : fix CI tests

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

1276 of 1461 branches covered (87.34%)

Branch coverage included in aggregate %.

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

117 existing lines in 15 files now uncovered.

3596 of 3883 relevant lines covered (92.61%)

4.24 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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