• 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

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

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

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

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

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

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

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

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

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

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

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

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

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