• 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

82.35
/common/src/main/java/io/github/hyshmily/hotkey/autoconfigure/HotKeyAmqpAutoConfiguration.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.constants.HotKeyConstants.ROUTING_KEY_HEARTBEAT;
19

20
import com.github.benmanes.caffeine.cache.Cache;
21
import io.github.hyshmily.hotkey.Internal;
22
import io.github.hyshmily.hotkey.cache.cachesupport.ExpireManager;
23
import io.github.hyshmily.hotkey.cache.loader.CacheLoader;
24
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
25
import io.github.hyshmily.hotkey.reporting.BbrRateLimiter;
26
import io.github.hyshmily.hotkey.reporting.KeyReporter;
27
import io.github.hyshmily.hotkey.reporting.ReportPublisher;
28
import io.github.hyshmily.hotkey.reporting.impl.BbrRateLimiterImpl;
29
import io.github.hyshmily.hotkey.reporting.impl.KeyReporterImpl;
30
import io.github.hyshmily.hotkey.rule.RuleMatcher;
31
import io.github.hyshmily.hotkey.sharding.HealthView;
32
import io.github.hyshmily.hotkey.sharding.RingManager;
33
import io.github.hyshmily.hotkey.sharding.impl.HealthViewImpl;
34
import io.github.hyshmily.hotkey.sharding.impl.RingManagerImpl;
35
import io.github.hyshmily.hotkey.sync.local.CacheSyncListener;
36
import io.github.hyshmily.hotkey.sync.local.CacheSyncProperties;
37
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
38
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
39
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatVerifier;
40
import io.github.hyshmily.hotkey.sync.worker.WorkerListener;
41
import io.github.hyshmily.hotkey.sync.worker.WorkerListenerProperties;
42
import io.github.hyshmily.hotkey.util.HotKeyThreadFactory;
43
import io.github.hyshmily.hotkey.util.InstanceIdGenerator;
44
import io.github.hyshmily.hotkey.util.SystemLoadMonitor;
45
import io.github.hyshmily.hotkey.util.impl.SystemLoadMonitorImpl;
46
import io.github.hyshmily.hotkey.util.ratelimit.SreRateLimiter;
47
import io.github.hyshmily.hotkey.util.ratelimit.impl.SreRateLimiterImpl;
48
import java.util.concurrent.Executors;
49
import java.util.concurrent.ScheduledExecutorService;
50
import org.springframework.amqp.core.*;
51
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
52
import org.springframework.amqp.rabbit.core.RabbitTemplate;
53
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
54
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
55
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
56
import org.springframework.amqp.support.converter.MessageConverter;
57
import org.springframework.beans.factory.ObjectProvider;
58
import org.springframework.beans.factory.annotation.Qualifier;
59
import org.springframework.boot.autoconfigure.AutoConfiguration;
60
import org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration;
61
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
62
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
63
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
64
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
65
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
66
import org.springframework.boot.context.properties.EnableConfigurationProperties;
67
import org.springframework.context.annotation.Bean;
68
import org.springframework.context.annotation.Configuration;
69
import org.springframework.data.redis.core.RedisTemplate;
70
import org.springframework.data.redis.core.StringRedisTemplate;
71

72
/**
73
 * Unified AMQP auto-configuration for HotKey messaging: app-to-Worker reporting,
74
 * instance-to-instance cache sync, and Worker decision listening.
75
 *
76
 * <p>Conditionally activates when {@link RabbitTemplate} is on the classpath.
77
 * Sub-groups for cache sync and Worker listener additionally require Redis.
78
 *
79
 * <p><b>Report</b> ({@code hotkey.report.enabled}, default {@code true}):
80
 * app instance aggregates access counts and sends them to the Worker via
81
 * {@link DirectExchange}. No Redis dependency.
82
 *
83
 * <p><b>Cache Sync</b> ({@code hotkey.sync.enabled=true}):
84
 * instance-to-instance INVALIDATE / REFRESH broadcasts via {@link FanoutExchange}.
85
 * Requires Redis for version tracking.
86
 *
87
 * <p><b>Worker Listener</b> ({@code hotkey.worker-listener.enabled=true}):
88
 * receives HOT/COOL decisions from the Worker via {@link FanoutExchange}.
89
 * Requires Redis.
90
 */
91
@Internal
92
@AutoConfiguration(after = { RedisAutoConfiguration.class, RabbitAutoConfiguration.class })
93
@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate")
94
@EnableConfigurationProperties({ HotKeyProperties.class, CacheSyncProperties.class, WorkerListenerProperties.class })
95
public class HotKeyAmqpAutoConfiguration {
3✔
96

97
  /**
98
   * Inner configuration for app-to-Worker report routing via DirectExchange.
99
   * Creates the exchange, publisher, ring manager (optional), reporter, and report scheduler.
100
   * Active by default when a {@link RabbitTemplate} bean is present.
101
   */
102
  @Configuration
103
  @ConditionalOnBean(RabbitTemplate.class)
104
  @ConditionalOnProperty(prefix = "hotkey.report", name = "enabled", havingValue = "true", matchIfMissing = true)
105
  static class ReportConfiguration {
3✔
106

107
    /**
108
     * Declare the DirectExchange for report routing (app → Worker).
109
     * Routing keys ({@code report.<appName>.<nodeId>}) ensure each key's
110
     * messages land on the correct worker queue.
111
     *
112
     * @param properties the HotKey configuration properties
113
     * @return a durable, non-auto-delete {@link DirectExchange}
114
     */
115
    @Bean
116
    public DirectExchange hotkeyReportExchange(HotKeyProperties properties) {
117
      return new DirectExchange(properties.getReportExchange(), true, false);
8✔
118
    }
119

120
    /**
121
     * Create the {@link MessageConverter} for serializing report messages to JSON.
122
     * <p>
123
     * Uses Jackson JSON serialization (not Java serialization) for efficiency and cross-version
124
     * compatibility.
125
     *
126
     * @return a new {@link Jackson2JsonMessageConverter} instance
127
     */
128
    @Bean
129
    @ConditionalOnMissingBean(MessageConverter.class)
130
    public MessageConverter reportMessageConverter() {
131
      return new Jackson2JsonMessageConverter();
4✔
132
    }
133

134
    /**
135
     * Create the {@link ReportPublisher} for sending batched access-count reports to the Worker.
136
     *
137
     * @param rabbitTemplate the RabbitMQ template for publishing messages
138
     * @param properties     the HotKey configuration properties
139
     * @return a new {@link ReportPublisher} instance
140
     */
141
    @Bean
142
    @ConditionalOnMissingBean
143
    public ReportPublisher reportPublisher(RabbitTemplate rabbitTemplate, HotKeyProperties properties) {
144
      return new ReportPublisher(rabbitTemplate, properties.getReportExchange(), properties.getAppName());
9✔
145
    }
146

147
    /**
148
     * Create the {@link RingManager} for consistent-hashing report routing.
149
     *
150
     * @param properties the HotKey configuration properties
151
     * @return a new {@link RingManager} instance
152
     */
153
    @Bean
154
    @ConditionalOnMissingBean
155
    public RingManager ringManager(HotKeyProperties properties) {
156
      return new RingManagerImpl(properties.getConsistentHashing().getVirtualNodes());
7✔
157
    }
158

159
    /**
160
     * Create the system CPU monitor with EMA smoothing.
161
     * <p>
162
     * Uses the JDK platform MXBean ({@link com.sun.management.OperatingSystemMXBean})
163
     * which is already used by the Worker-side heartbeat producer. The monitor
164
     * starts sampling on creation and stops on context close.
165
     *
166
     * @param properties the HotKey configuration properties
167
     * @return a new {@link SystemLoadMonitor} instance
168
     */
169
    @Bean(initMethod = "start", destroyMethod = "stop")
170
    @ConditionalOnMissingBean
171
    public SystemLoadMonitor hotKeyCpuMonitor(
172
      HotKeyProperties properties,
173
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
174
    ) {
175
      HotKeyProperties.ReporterLimiter cfg = properties.getReporter();
3✔
176
      return new SystemLoadMonitorImpl(hotKeyScheduler, cfg.getCpuPollIntervalMs(), cfg.getCpuDecay());
9✔
177
    }
178

179
    /**
180
     * Create the BBR adaptive rate limiter for the report publisher.
181
     * <p>
182
     * Uses the CPU monitor and the configured BBR parameters. When disabled
183
     * (or when the CPU monitor itself hasn't been fully initialized yet),
184
     * the limiter falls back to a permissive mode.
185
     *
186
     * @param cpuMonitor the system CPU load monitor
187
     * @param properties the HotKey configuration properties
188
     * @return a new {@link BbrRateLimiter} instance
189
     */
190
    @Bean
191
    @ConditionalOnMissingBean
192
    @ConditionalOnProperty(
193
      prefix = "hotkey.local.reporter",
194
      name = "enabled",
195
      havingValue = "true",
196
      matchIfMissing = true
197
    )
198
    public BbrRateLimiterImpl hotKeyBbrRateLimiter(SystemLoadMonitor cpuMonitor, HotKeyProperties properties) {
199
      HotKeyProperties.ReporterLimiter cfg = properties.getReporter();
3✔
200
      return new BbrRateLimiterImpl(
5✔
201
        cpuMonitor,
202
        cfg.getCpuThreshold(),
2✔
203
        cfg.getBbrWindowMs(),
2✔
204
        cfg.getBbrWindowBuckets(),
2✔
205
        cfg.getBbrCooldownMs()
2✔
206
      );
207
    }
208

209
    /**
210
     * Create the {@link KeyReporter} that aggregates per-key counts and flushes them
211
     * at the configured interval.
212
     *
213
     * @param reportPublisher       the report publisher for sending batches
214
     * @param properties            the HotKey configuration properties
215
     * @param ringManager           the consistent-hash ring manager
216
     * @param healthViewProvider    optional provider for the cluster health view
217
     * @param bbrRateLimiterProvider optional provider for the BBR rate limiter
218
     * @return a new {@link KeyReporterImpl} instance
219
     */
220
    @Bean(initMethod = "start", destroyMethod = "stop")
221
    @ConditionalOnMissingBean
222
    public KeyReporter hotKeyReporter(
223
      ReportPublisher reportPublisher,
224
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler,
225
      HotKeyProperties properties,
226
      RingManager ringManager,
227
      ObjectProvider<HealthView> healthViewProvider,
228
      ObjectProvider<BbrRateLimiterImpl> bbrRateLimiterProvider
229
    ) {
230
      KeyReporterImpl reporter = new KeyReporterImpl(
5✔
231
        reportPublisher,
232
        hotKeyScheduler,
233
        properties.getReportIntervalMs(),
2✔
234
        properties.getAppName(),
2✔
235
        properties.getQueueCapacity(),
2✔
236
        properties.getQueueOfferTimeoutMs(),
2✔
237
        properties.effectiveConsumerCount(),
5✔
238
        ringManager,
239
        healthViewProvider.getIfAvailable(() ->
4✔
NEW
240
          new HealthViewImpl(
×
241
            properties.getExpectedWorkerCount(),
×
242
            properties.getHeartbeat().getTimeoutMs(),
×
243
            properties.getHeartbeat().getDegradeAfterFailures()
×
244
          )
245
        )
246
      );
247
      bbrRateLimiterProvider.ifAvailable(reporter::setBbrRateLimiter);
7✔
248
      return reporter;
2✔
249
    }
250
  }
251

252
  /**
253
   * Inner configuration for instance-to-instance cache synchronization.
254
   * Creates a FanoutExchange, per-instance queue with TTL, binding, publisher,
255
   * Redis loader, sync listener, and a dedicated scheduled executor.
256
   * Requires Redis and {@code hotkey.sync.enabled=true}.
257
   */
258
  @Configuration
259
  @ConditionalOnClass(name = "org.springframework.data.redis.core.RedisTemplate")
260
  @ConditionalOnProperty(prefix = "hotkey.sync", name = "enabled", havingValue = "true")
261
  @lombok.extern.slf4j.Slf4j
4✔
262
  static class SyncConfiguration {
3✔
263

264
    /**
265
     * Create the FanoutExchange for broadcasting INVALIDATE/REFRESH messages
266
     * to all app instances.
267
     *
268
     * @param properties the cache sync configuration properties
269
     * @return a durable, non-auto-delete {@link FanoutExchange}
270
     */
271
    @Bean
272
    @ConditionalOnClass(name = "org.springframework.amqp.core.FanoutExchange")
273
    public FanoutExchange hotkeySyncExchange(CacheSyncProperties properties) {
274
      return new FanoutExchange(properties.getExchangeName(), true, false);
8✔
275
    }
276

277
    /**
278
     * Create the per-instance sync queue with a 60-second message TTL and 24-hour idle expiry.
279
     *
280
     * @param properties the cache sync configuration properties
281
     * @return a durable {@link Queue} with {@code x-message-ttl} of 60 seconds
282
     *         and {@code x-expires} of 24 hours
283
     */
284
    @Bean
285
    @ConditionalOnClass(name = "org.springframework.amqp.core.Queue")
286
    public Queue hotkeySyncQueue(CacheSyncProperties properties) {
287
      return QueueBuilder.durable(properties.getQueueName())
6✔
288
        .withArgument("x-message-ttl", 60_000)
4✔
289
        .withArgument("x-expires", 86_400_000)
2✔
290
        .build();
1✔
291
    }
292

293
    /**
294
     * Bind the per-instance queue to the sync exchange.
295
     *
296
     * @param hotkeySyncQueue    the per-instance sync queue
297
     * @param hotkeySyncExchange the sync FanoutExchange
298
     * @return a {@link Binding} connecting the queue to the exchange
299
     */
300
    @Bean
301
    public Binding hotkeySyncBinding(Queue hotkeySyncQueue, FanoutExchange hotkeySyncExchange) {
302
      return BindingBuilder.bind(hotkeySyncQueue).to(hotkeySyncExchange);
5✔
303
    }
304

305
    /**
306
     * Create the cache sync publisher for sending INVALIDATE/REFRESH messages.
307
     *
308
     * @param rabbitTemplate the RabbitMQ template for publishing messages
309
     * @param properties     the cache sync configuration properties
310
     * @return a new {@link CacheSyncPublisher} instance
311
     */
312
    @Bean
313
    @ConditionalOnMissingBean
314
    public CacheSyncPublisher cacheSyncPublisher(RabbitTemplate rabbitTemplate, CacheSyncProperties properties) {
315
      return new CacheSyncPublisher(rabbitTemplate, properties);
6✔
316
    }
317

318
    /**
319
     * Dedicated scheduler for jitter-delayed cache-update tasks received from
320
     * peer instances. Isolated from {@code hotKeyScheduler} so that synchronous
321
     * Redis GETs performed by {@code handleRefresh} can never starve the
322
     * 50 ms reporter flush tick.
323
     *
324
     * <p>Pool size is {@code hotkey.sync.scheduler-pool-size} (default 4) but
325
     * should be at least {@code hotkey.sync.concurrent-consumers × 2}.
326
     *
327
     * @param properties the cache sync configuration properties
328
     * @return a daemon-thread scheduled executor named {@code hotkey-sync-sched-N}
329
     */
330
    @Bean(name = "hotKeySyncScheduler", destroyMethod = "shutdown")
331
    @ConditionalOnMissingBean(name = "hotKeySyncScheduler")
332
    public ScheduledExecutorService hotKeySyncScheduler(CacheSyncProperties properties) {
333
      int poolSize = Math.max(properties.getSchedulerPoolSize(), properties.getConcurrentConsumers() * 2);
×
334
      return Executors.newScheduledThreadPool(
×
335
        poolSize,
336
        new HotKeyThreadFactory(HotKeyConstants.THREAD_PREFIX_SCHEDULER + "-sync")
337
      );
338
    }
339

340
    /**
341
     * Default Redis loader used by the sync listener to refresh cache entries via {@code GET}.
342
     *
343
     * @param stringRedisTemplate the String-based Redis template for reading values
344
     * @return a {@link CacheLoader} that reads a key from Redis and returns its value
345
     */
346
    @Bean
347
    @ConditionalOnMissingBean(CacheLoader.class)
348
    public CacheLoader hotKeyRedisLoader(StringRedisTemplate stringRedisTemplate) {
349
      return new io.github.hyshmily.hotkey.cache.loader.RedisCacheLoader(stringRedisTemplate);
5✔
350
    }
351

352
    /**
353
     * Create the sync listener that handles incoming INVALIDATE/REFRESH messages from peers.
354
     *
355
     * @param hotLocalCache       the L1 Caffeine cache
356
     * @param hotKeyRedisLoader   the function for loading values from Redis
357
     * @param properties          the cache sync configuration properties
358
     * @param expireManager       the cache expiry manager
359
     * @param ruleMatcher         the rule matcher for key matching
360
     * @return a new {@link CacheSyncListener} instance
361
     */
362
    @Bean
363
    @ConditionalOnMissingBean
364
    public CacheSyncListener cacheSyncListener(
365
      Cache<String, Object> hotLocalCache,
366
      CacheLoader hotKeyRedisLoader,
367
      CacheSyncProperties properties,
368
      @Qualifier("hotKeySyncScheduler") ScheduledExecutorService syncScheduler,
369
      ExpireManager expireManager,
370
      RuleMatcher ruleMatcher
371
    ) {
372
      return new CacheSyncListener(
10✔
373
        hotLocalCache,
374
        hotKeyRedisLoader,
375
        properties,
376
        syncScheduler,
377
        expireManager,
378
        ruleMatcher
379
      );
380
    }
381

382
    /**
383
     * Create the AMQP message listener container that drives the sync listener.
384
     *
385
     * @param connectionFactory  the RabbitMQ connection factory
386
     * @param cacheSyncListener  the sync message handler
387
     * @param properties         the cache sync configuration properties
388
     * @return a configured {@link SimpleMessageListenerContainer}
389
     */
390
    @Bean
391
    @ConditionalOnBean(ConnectionFactory.class)
392
    public SimpleMessageListenerContainer syncListenerContainer(
393
      ConnectionFactory connectionFactory,
394
      CacheSyncListener cacheSyncListener,
395
      CacheSyncProperties properties
396
    ) {
397
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
398
      container.setQueueNames(properties.getQueueName());
9✔
399
      container.setAutoStartup(properties.isAutoStartup());
4✔
400
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
401
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
402
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
403
      container.setErrorHandler(t ->
3✔
404
        log.warn("Sync listener uncaught exception (message will be requeued by container)", t)
×
405
      );
406
      container.setMessageListener(
4✔
407
        (ChannelAwareMessageListener) (msg, channel) -> cacheSyncListener.handleSyncMessage(channel, msg)
×
408
      );
409
      return container;
2✔
410
    }
411
  }
412

413
  /**
414
   * Inner configuration for receiving Worker HOT/COOL decisions.
415
   * Creates a FanoutExchange, per-instance queue with TTL, binding, worker listener,
416
   * listener container, and a dedicated scheduled executor.
417
   * Requires Redis and {@code hotkey.worker-listener.enabled=true}.
418
   */
419
  @Configuration
420
  @ConditionalOnClass(name = "org.springframework.data.redis.core.RedisTemplate")
421
  @ConditionalOnBean(RedisTemplate.class)
422
  @ConditionalOnProperty(prefix = "hotkey.worker-listener", name = "enabled", havingValue = "true")
423
  @lombok.extern.slf4j.Slf4j
4✔
424
  static class WorkerListenerConfiguration {
3✔
425

426
    /**
427
     * Create the FanoutExchange for broadcasting Worker HOT/COOL decisions
428
     * to all app instances.
429
     *
430
     * @param properties the Worker listener configuration properties
431
     * @return a durable, non-auto-delete {@link FanoutExchange}
432
     */
433
    @Bean
434
    @ConditionalOnMissingBean(name = "hotkeyWorkerExchange")
435
    public FanoutExchange hotkeyWorkerExchange(WorkerListenerProperties properties) {
436
      return new FanoutExchange(properties.getExchangeName(), true, false);
8✔
437
    }
438

439
    /**
440
     * Create the per-instance Worker listener queue with a 60-second message TTL and 24-hour idle expiry.
441
     *
442
     * @param properties the Worker listener configuration properties
443
     * @return a durable {@link Queue} with {@code x-message-ttl} of 60 seconds
444
     *         and {@code x-expires} of 24 hours
445
     */
446
    @Bean
447
    @ConditionalOnMissingBean(name = "hotkeyWorkerQueue")
448
    public Queue hotkeyWorkerQueue(WorkerListenerProperties properties) {
449
      return QueueBuilder.durable(properties.getQueueName())
6✔
450
        .withArgument("x-message-ttl", 60_000)
4✔
451
        .withArgument("x-expires", 86_400_000)
2✔
452
        .build();
1✔
453
    }
454

455
    /**
456
     * Bind the per-instance queue to the Worker exchange.
457
     *
458
     * @param hotkeyWorkerQueue    the per-instance Worker listener queue
459
     * @param hotkeyWorkerExchange the Worker FanoutExchange
460
     * @return a {@link Binding} connecting the queue to the exchange
461
     */
462
    @Bean
463
    public Binding hotkeyWorkerBinding(Queue hotkeyWorkerQueue, FanoutExchange hotkeyWorkerExchange) {
464
      return BindingBuilder.bind(hotkeyWorkerQueue).to(hotkeyWorkerExchange);
5✔
465
    }
466

467
    /**
468
     * Create the TopicExchange for Worker heartbeat broadcasts.
469
     *
470
     * @param properties the HotKey configuration properties
471
     * @return a durable, non-auto-delete {@link TopicExchange}
472
     */
473
    @Bean
474
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatExchange")
475
    public TopicExchange hotkeyHeartbeatExchange(HotKeyProperties properties) {
476
      return new TopicExchange(properties.getHeartbeat().getExchangeName(), true, false);
9✔
477
    }
478

479
    /**
480
     * Create the per-instance non-durable heartbeat queue that auto-deletes on disconnect.
481
     *
482
     * @return a non-durable, auto-delete {@link Queue}
483
     */
484
    @Bean
485
    public Queue hotkeyHeartbeatQueue() {
486
      return QueueBuilder.nonDurable("hotkey.heartbeat:" + InstanceIdGenerator.get()).autoDelete().build();
6✔
487
    }
488

489
    /**
490
     * Bind the per-instance heartbeat queue to the heartbeat exchange with routing key {@code heartbeat.*}.
491
     *
492
     * @param hotkeyHeartbeatQueue    the per-instance heartbeat queue
493
     * @param hotkeyHeartbeatExchange the heartbeat TopicExchange
494
     * @return a {@link Binding} connecting the queue to the exchange
495
     */
496
    @Bean
497
    public Binding hotkeyHeartbeatBinding(Queue hotkeyHeartbeatQueue, TopicExchange hotkeyHeartbeatExchange) {
498
      return BindingBuilder.bind(hotkeyHeartbeatQueue).to(hotkeyHeartbeatExchange).with(ROUTING_KEY_HEARTBEAT + "*");
7✔
499
    }
500

501
    /**
502
     * Create the SRE adaptive rate limiter for WorkerListener HOT-path throttling.
503
     * <p>
504
     * Disabled when {@code hotkey.worker-listener.sre.enabled=false}.
505
     *
506
     * @param properties the Worker listener configuration properties
507
     * @return a new {@link SreRateLimiter} instance
508
     */
509
    @Bean
510
    @ConditionalOnMissingBean
511
    @ConditionalOnProperty(
512
      prefix = "hotkey.worker-listener.sre",
513
      name = "enabled",
514
      havingValue = "true",
515
      matchIfMissing = true
516
    )
517
    public SreRateLimiterImpl hotKeySreRateLimiter(WorkerListenerProperties properties) {
518
      WorkerListenerProperties.Sre sreConfig = properties.getSre();
3✔
519
      return new SreRateLimiterImpl(
4✔
520
        sreConfig.getWindowMs(),
2✔
521
        sreConfig.getBuckets(),
3✔
522
        1.0 / sreConfig.getSuccessThreshold(),
3✔
523
        sreConfig.getMinSamples()
2✔
524
      );
525
    }
526

527
    /**
528
     * Dedicated scheduler for jitter-delayed cache-update tasks received from
529
     * the Worker (HOT/COOL decisions). Isolated from {@code hotKeyScheduler}
530
     * so that synchronous Redis GETs performed by {@code handleHot} can never
531
     * starve the reporter flush tick.
532
     *
533
     * @param properties the Worker listener configuration properties
534
     * @return a daemon-thread scheduled executor named {@code hotkey-worker-sched-N}
535
     */
536
    @Bean(name = "hotKeyWorkerSchedScheduler", destroyMethod = "shutdown")
537
    @ConditionalOnMissingBean(name = "hotKeyWorkerSchedScheduler")
538
    public ScheduledExecutorService hotKeyWorkerSchedScheduler(WorkerListenerProperties properties) {
539
      int poolSize = Math.max(properties.getSchedulerPoolSize(), properties.getConcurrentConsumers() * 2);
×
540
      return Executors.newScheduledThreadPool(
×
541
        poolSize,
542
        new HotKeyThreadFactory(HotKeyConstants.THREAD_PREFIX_SCHEDULER + "-worker")
543
      );
544
    }
545

546
    /**
547
     * Create the listener that processes HOT/COOL decisions broadcast by the Worker.
548
     *
549
     * @param hotLocalCache          the L1 Caffeine cache
550
     * @param hotKeyRedisLoader      the function for loading values from Redis
551
     * @param properties             the Worker listener configuration properties
552
     * @param expireManager          the cache expiry manager
553
     * @param sreRateLimiterProvider optional provider for the SRE rate limiter
554
     * @return a new {@link WorkerListener} instance
555
     */
556
    @Bean
557
    @ConditionalOnMissingBean
558
    public WorkerListener workerListener(
559
      Cache<String, Object> hotLocalCache,
560
      CacheLoader hotKeyRedisLoader,
561
      WorkerListenerProperties properties,
562
      @Qualifier("hotKeyWorkerSchedScheduler") ScheduledExecutorService workerSchedScheduler,
563
      ExpireManager expireManager,
564
      ObjectProvider<SreRateLimiterImpl> sreRateLimiterProvider
565
    ) {
566
      return new WorkerListener(
9✔
567
        hotLocalCache,
568
        hotKeyRedisLoader,
569
        properties,
570
        workerSchedScheduler,
571
        expireManager,
572
        sreRateLimiterProvider.getIfAvailable()
3✔
573
      );
574
    }
575

576
    /**
577
     * Create the AMQP message listener container that processes Worker heartbeat messages.
578
     *
579
     * @param connectionFactory   the RabbitMQ connection factory
580
     * @param healthView          the cluster health view to update on heartbeat reception
581
     * @param hotkeyHeartbeatQueue the heartbeat queue
582
     * @return a configured {@link SimpleMessageListenerContainer}
583
     */
584
    @Bean
585
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatContainer")
586
    public SimpleMessageListenerContainer heartbeatContainer(
587
      ConnectionFactory connectionFactory,
588
      HealthView healthView,
589
      Queue hotkeyHeartbeatQueue
590
    ) {
591
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
592
      container.setQueueNames(hotkeyHeartbeatQueue.getName());
9✔
593
      container.setAcknowledgeMode(AcknowledgeMode.NONE);
3✔
594
      container.setConcurrentConsumers(1);
3✔
595
      container.setPrefetchCount(100);
3✔
596
      container.setErrorHandler(t -> log.warn("Heartbeat listener uncaught exception (message discarded)", t));
3✔
597
      container.setMessageListener(msg -> {
4✔
598
        WorkerHeartbeatMessage hb = WorkerHeartbeatMessage.from(msg);
×
599
        if (hb != null) {
×
600
          healthView.onHeartbeat(hb);
×
601
        }
602
      });
×
603
      return container;
2✔
604
    }
605

606
    /**
607
     * Create the AMQP message listener container that processes Worker HOT/COOL decisions
608
     * via the {@link WorkerListener}.
609
     *
610
     * <p>The container uses {@link AcknowledgeMode#MANUAL} because the
611
     * {@link WorkerListener#handleWorkerMessage} performs its own ack/nack
612
     * (ack-before-update pattern, see ADR-0004).
613
     *
614
     * @param connectionFactory the RabbitMQ connection factory
615
     * @param hotkeyWorkerQueue the per-instance Worker listener queue
616
     * @param workerListener    the Worker decision listener
617
     * @param properties        the Worker listener configuration properties
618
     * @return a configured {@link SimpleMessageListenerContainer}
619
     */
620
    @Bean
621
    @ConditionalOnMissingBean(name = "workerListenerContainer")
622
    public SimpleMessageListenerContainer workerListenerContainer(
623
      ConnectionFactory connectionFactory,
624
      Queue hotkeyWorkerQueue,
625
      WorkerListener workerListener,
626
      WorkerListenerProperties properties
627
    ) {
628
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
629
      container.setQueueNames(hotkeyWorkerQueue.getName());
9✔
630
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
631
      container.setMessageListener(
4✔
632
        (ChannelAwareMessageListener) (msg, channel) -> workerListener.handleWorkerMessage(channel, msg)
×
633
      );
634
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
635
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
636
      container.setErrorHandler(t ->
3✔
637
        log.warn("Worker listener uncaught exception (message will be requeued by container)", t)
×
638
      );
639
      container.setAutoStartup(properties.isAutoStartup());
4✔
640
      return container;
2✔
641
    }
642

643
    /**
644
     * Create the {@link WorkerHeartbeatVerifier} that periodically PINGs Workers
645
     * to verify they are alive.
646
     *
647
     * @param rabbitTemplate the RabbitMQ template for sending PING messages
648
     * @param healthView     the cluster health view to update on verification results
649
     * @param properties     the HotKey configuration properties
650
     * @return a new {@link WorkerHeartbeatVerifier} instance
651
     */
652
    @Bean(initMethod = "start", destroyMethod = "stop")
653
    @ConditionalOnMissingBean
654
    public WorkerHeartbeatVerifier workerHeartbeatVerifier(
655
      RabbitTemplate rabbitTemplate,
656
      HealthView healthView,
657
      HotKeyProperties properties,
658
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
659
    ) {
660
      return new WorkerHeartbeatVerifier(
6✔
661
        rabbitTemplate,
662
        healthView,
663
        properties.getInstanceId(),
4✔
664
        new WorkerHeartbeatVerifier.VerifierConfig(
665
          properties.getHeartbeat().getVerifyIntervalMs(),
3✔
666
          properties.getHeartbeat().getPingTimeoutMs(),
3✔
667
          properties.getHeartbeat().getVerifyMaxBackoffMs()
5✔
668
        ),
669
        hotKeyScheduler
670
      );
671
    }
672
  }
673
}
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