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

Hyshmily / hotkey / 28738321635

05 Jul 2026 10:51AM UTC coverage: 90.457% (+0.04%) from 90.421%
28738321635

push

github

Hyshmily
refactor: extract interfaces for 12 core classes (Batch 1+2)

Extract interfaces from concrete classes, impls moved to impl/ subpackages:

Batch 1 (HotPath):
- CircuitBreaker, ExpireManager, SingleFlight
- KeyReporter, RuleMatcher, HealthView

Batch 2 (Supporting):
- VersionController, RingManager, HotKeyStateMachine
- SystemLoadMonitor, BbrRateLimiter, SreRateLimiter

Pattern: interface at original package, impl named XxxImpl under impl/

1296 of 1498 branches covered (86.52%)

Branch coverage included in aggregate %.

448 of 480 new or added lines in 19 files covered. (93.33%)

3690 of 4014 relevant lines covered (91.93%)

4.19 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

369
          /**
370
           * Preserve the current expiry duration on read — reads never
371
           * extend or shorten the entry's time-to-live.
372
           *
373
           * @param key              the cache key
374
           * @param value            the cache value
375
           * @param currentTimeNanos the current time in nanoseconds (provided by Caffeine)
376
           * @param currentDuration  the current expiry duration in nanoseconds
377
           * @return the unchanged expiry duration in nanoseconds
378
           */
379
          @Override
380
          public long expireAfterRead(
381
            @NonNull Object key,
382
            @NonNull Object value,
383
            long currentTimeNanos,
384
            long currentDuration
385
          ) {
386
            return currentDuration;
×
387
          }
388
        }
389
      );
390
    return builder.build();
3✔
391
  }
392
}
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